@kernhq/module-inventory 0.1.2 → 0.3.0
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 +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/meta/0000_snapshot.json +40 -13
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +191 -59
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
package/src/client/mock.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Asset,
|
|
3
|
+
AssetHistoryEntry,
|
|
4
|
+
AssetStatus,
|
|
5
|
+
Attachment,
|
|
6
|
+
Category,
|
|
7
|
+
CustodyPeriod,
|
|
8
|
+
InventoryStats,
|
|
9
|
+
Repair,
|
|
10
|
+
} from '../contract/index.js'
|
|
2
11
|
|
|
3
12
|
/**
|
|
4
13
|
* The in-memory implementation of this module's API.
|
|
@@ -8,65 +17,679 @@ import type { Asset } from '../contract.js'
|
|
|
8
17
|
* never checks an env var itself.
|
|
9
18
|
*
|
|
10
19
|
* Keep it in step with the contract. A module whose mock is missing a procedure has a working page
|
|
11
|
-
* and a broken demo, in exactly the environment used to show the product
|
|
20
|
+
* and a broken demo, in exactly the environment used to show the product — and one whose mock
|
|
21
|
+
* ignores a filter has a demo that contradicts its own controls.
|
|
22
|
+
*
|
|
23
|
+
* **It contradicted the product in five measured ways, and this file is the answer to all five.**
|
|
24
|
+
* It returned seed order while the server defaults to newest first; it issued `INV-0013` for the
|
|
25
|
+
* seventh asset because one counter was doing two jobs; it served page one for ever for any cursor
|
|
26
|
+
* it did not recognise, which is an endless "Load more" where the server answers `BAD_REQUEST`; and
|
|
27
|
+
* it ignored `sort`, `categoryId` and `custodianUserId` entirely. So the ordering, the paging and
|
|
28
|
+
* the cursor format below are deliberately the same as `src/server/services/assets.ts` — read them
|
|
29
|
+
* as one pair, and change them as one pair.
|
|
30
|
+
*
|
|
31
|
+
* Custody keeps the same invariant the server keeps, for the same reason: a handover closes the open
|
|
32
|
+
* period, opens a new one, moves `custodianUserId`/`custodySince`/`status` and appends a history
|
|
33
|
+
* row, and it refuses the three things the server refuses — assigning something already held,
|
|
34
|
+
* handing on something nobody holds, and touching an archived item. A demo that lets you assign the
|
|
35
|
+
* same laptop twice teaches somebody the product does.
|
|
36
|
+
*
|
|
37
|
+
* Repairs keep the same invariant too, including the one that is easiest to get wrong: **an item at
|
|
38
|
+
* a repairer is still whoever's it was**, so a repair moves `status` and never `custodianUserId`,
|
|
39
|
+
* and completing one returns the item to `assigned` rather than to `in_stock` when somebody still
|
|
40
|
+
* holds it. `statusFor` below is this file's copy of `deriveStatus` on the server; read them as one
|
|
41
|
+
* pair and change them as one pair.
|
|
42
|
+
*/
|
|
43
|
+
type AssetSort = 'recent' | 'name' | 'code'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The people the **shell's** own mock puts in this workspace.
|
|
47
|
+
*
|
|
48
|
+
* Copied ids rather than a fetch, because this file has no client and no way to ask. The coupling is
|
|
49
|
+
* deliberate and narrow: without it every name in the custody tab and the timeline would resolve to
|
|
50
|
+
* "A former member", which is a demo of the fallback rather than a demo of the feature. If the
|
|
51
|
+
* shell's mock people ever change, this is where the demo stops naming them.
|
|
52
|
+
*/
|
|
53
|
+
const PEOPLE = {
|
|
54
|
+
maya: '01920000-0000-7000-8000-000000000001',
|
|
55
|
+
dan: '01920000-0000-7000-8000-000000000002',
|
|
56
|
+
ines: '01920000-0000-7000-8000-000000000003',
|
|
57
|
+
} as const
|
|
58
|
+
|
|
59
|
+
interface CategorySeed {
|
|
60
|
+
key: string
|
|
61
|
+
name: string
|
|
62
|
+
order: number
|
|
63
|
+
archived?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CATEGORY_SEEDS: CategorySeed[] = [
|
|
67
|
+
{ key: 'laptops', name: 'Laptops', order: 0 },
|
|
68
|
+
{ key: 'displays', name: 'Displays', order: 1 },
|
|
69
|
+
{ key: 'furniture', name: 'Furniture', order: 2 },
|
|
70
|
+
{ key: 'cameras', name: 'Cameras', order: 3 },
|
|
71
|
+
// One archived, so the demo shows what archiving actually does: it leaves the picker and the
|
|
72
|
+
// filter, and the asset filed under it goes on saying what it is.
|
|
73
|
+
{ key: 'phones', name: 'Phones', order: 4, archived: true },
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
interface Seed {
|
|
77
|
+
code: string
|
|
78
|
+
name: string
|
|
79
|
+
status: AssetStatus
|
|
80
|
+
categoryKey?: string
|
|
81
|
+
/** Who is holding it. Kept in step with `status` here exactly as the server keeps it. */
|
|
82
|
+
custodian?: string
|
|
83
|
+
serialNumber?: string | null
|
|
84
|
+
location?: string | null
|
|
85
|
+
warrantyUntil?: string | null
|
|
86
|
+
archived?: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SEEDS: Seed[] = [
|
|
90
|
+
{
|
|
91
|
+
code: 'INV-0001',
|
|
92
|
+
name: 'MacBook Pro 14"',
|
|
93
|
+
status: 'assigned',
|
|
94
|
+
categoryKey: 'laptops',
|
|
95
|
+
custodian: PEOPLE.dan,
|
|
96
|
+
serialNumber: 'C02X1234JGH7',
|
|
97
|
+
location: 'Istanbul · 3rd floor',
|
|
98
|
+
warrantyUntil: '2027-03-14',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
code: 'INV-0002',
|
|
102
|
+
name: 'Dell UltraSharp 27"',
|
|
103
|
+
status: 'in_stock',
|
|
104
|
+
categoryKey: 'displays',
|
|
105
|
+
location: 'Istanbul · store room',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
code: 'INV-0003',
|
|
109
|
+
name: 'iPhone 15',
|
|
110
|
+
status: 'under_repair',
|
|
111
|
+
// Filed under the archived category on purpose: the row still names "Phones".
|
|
112
|
+
categoryKey: 'phones',
|
|
113
|
+
serialNumber: 'F17GX9QKLM',
|
|
114
|
+
location: 'With the repairer',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
code: 'INV-0004',
|
|
118
|
+
name: 'Herman Miller Aeron',
|
|
119
|
+
status: 'assigned',
|
|
120
|
+
categoryKey: 'furniture',
|
|
121
|
+
custodian: PEOPLE.ines,
|
|
122
|
+
location: 'Istanbul · 2nd floor',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
code: 'INV-0005',
|
|
126
|
+
name: 'Canon EOS R6',
|
|
127
|
+
status: 'reserved',
|
|
128
|
+
categoryKey: 'cameras',
|
|
129
|
+
location: 'Istanbul · store room',
|
|
130
|
+
},
|
|
131
|
+
{ code: 'INV-0006', name: 'ThinkPad X1 Carbon', status: 'retired', archived: true },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* An error shaped like one the real client surfaces.
|
|
136
|
+
*
|
|
137
|
+
* oRPC hands a failure to the screen as an `Error` carrying the contract's `code`, and a mock that
|
|
138
|
+
* throws a bare `Error` teaches a demo that every failure looks the same — hiding the two branches
|
|
139
|
+
* this file exists to reproduce, `BAD_REQUEST` on a stale page marker and `CONFLICT` on a handover
|
|
140
|
+
* somebody else got to first.
|
|
141
|
+
*
|
|
142
|
+
* **`data.reason` is part of that shape, and leaving it out made the demo lie in a new way.** The
|
|
143
|
+
* screens no longer render `error.message`: `errors.ts` reads the stable reason token each refusal
|
|
144
|
+
* carries and shows a translated sentence, keeping the server's English only for a failure it does
|
|
145
|
+
* not recognise. A mock that threw the message and no token would send every one of these down the
|
|
146
|
+
* fallback path — so the demo would show English prose where the product shows Persian, which is
|
|
147
|
+
* exactly the defect that was just fixed, reproduced by the thing meant to reproduce the product.
|
|
148
|
+
*
|
|
149
|
+
* The shape matches `kernErrorToORPC`: `{ reason, …details }` under `data`, not beside it.
|
|
150
|
+
*/
|
|
151
|
+
class MockApiError extends Error {
|
|
152
|
+
readonly data: { reason: string } | undefined
|
|
153
|
+
constructor(
|
|
154
|
+
readonly code: 'BAD_REQUEST' | 'NOT_FOUND' | 'CONFLICT',
|
|
155
|
+
message: string,
|
|
156
|
+
reason?: string,
|
|
157
|
+
) {
|
|
158
|
+
super(message)
|
|
159
|
+
this.name = 'MockApiError'
|
|
160
|
+
this.data = reason ? { reason } : undefined
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The server's page boundary, byte for byte: base64url of `{i: <row id>, s: <sort>}`.
|
|
166
|
+
*
|
|
167
|
+
* `btoa` rather than `Buffer` because this runs in a browser, and it is safe here for the reason
|
|
168
|
+
* the server's version is small: the payload is a uuid and a sort name, so it is pure ASCII.
|
|
169
|
+
*/
|
|
170
|
+
const base64url = (text: string) => btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
171
|
+
|
|
172
|
+
const fromBase64url = (text: string) =>
|
|
173
|
+
atob(
|
|
174
|
+
text
|
|
175
|
+
.replace(/-/g, '+')
|
|
176
|
+
.replace(/_/g, '/')
|
|
177
|
+
.padEnd(Math.ceil(text.length / 4) * 4, '='),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
181
|
+
|
|
182
|
+
const encodeCursor = (bookmark: { i: string; s: AssetSort }) => base64url(JSON.stringify(bookmark))
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Refuse a marker this list did not issue, exactly where the server refuses it.
|
|
186
|
+
*
|
|
187
|
+
* The old mock read an unknown cursor as "start at 0", so `nextCursor` was non-null for ever and
|
|
188
|
+
* "Load more" served page one until somebody closed the tab. A cursor bound to the wrong sort is
|
|
189
|
+
* the same class of bug and is refused for the same reason.
|
|
190
|
+
*/
|
|
191
|
+
function decodeCursor(cursor: string, sort: AssetSort): { i: string; s: AssetSort } {
|
|
192
|
+
const refuse = () => new MockApiError('BAD_REQUEST', 'That page marker is not one this list issued')
|
|
193
|
+
let parsed: { i?: unknown; s?: unknown } | null
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(fromBase64url(cursor)) as { i?: unknown; s?: unknown } | null
|
|
196
|
+
} catch {
|
|
197
|
+
throw refuse()
|
|
198
|
+
}
|
|
199
|
+
if (typeof parsed?.i !== 'string' || !UUID.test(parsed.i)) throw refuse()
|
|
200
|
+
if (parsed.s !== sort) throw refuse()
|
|
201
|
+
return { i: parsed.i, s: sort }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Which field an ordering sorts on. `recent` is the id, because a uuidv7 is already in time order. */
|
|
205
|
+
const sortKeyOf = (asset: Asset, sort: AssetSort) =>
|
|
206
|
+
sort === 'name' ? asset.name : sort === 'code' ? asset.code : asset.id
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `(sortKey, id)` against `(sortKey, id)` — the tuple the server's row comparison uses.
|
|
210
|
+
*
|
|
211
|
+
* The id tiebreak is what makes the boundary total: two assets with the same name would otherwise
|
|
212
|
+
* have no defined order between them, and a page boundary that lands between them would repeat or
|
|
213
|
+
* skip a row. JS compares by code point where Postgres compares by collation, which is close enough
|
|
214
|
+
* for six seeded rows and is the one place this file is an approximation rather than a mirror.
|
|
12
215
|
*/
|
|
13
|
-
|
|
216
|
+
function compare(a: Asset, b: Asset, sort: AssetSort): number {
|
|
217
|
+
const ka = sortKeyOf(a, sort)
|
|
218
|
+
const kb = sortKeyOf(b, sort)
|
|
219
|
+
if (ka !== kb) return ka < kb ? -1 : 1
|
|
220
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
|
221
|
+
}
|
|
14
222
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const assets: MockAsset[] = [
|
|
18
|
-
seed('01920000-0000-7000-8000-000000000001', 'INV-0001', 'MacBook Pro 14"', 'assigned'),
|
|
19
|
-
]
|
|
223
|
+
/** Days ago, as an ISO instant — so a seeded timeline reads as history rather than as "just now". */
|
|
224
|
+
const daysAgo = (days: number) => new Date(Date.now() - days * 864e5).toISOString()
|
|
20
225
|
|
|
21
|
-
|
|
226
|
+
/** The same instant as a plain date, which is what a `date` column stores. */
|
|
227
|
+
const dateDaysAgo = (days: number) => daysAgo(days).slice(0, 10)
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* What core knows about a file, as far as this module is concerned.
|
|
231
|
+
*
|
|
232
|
+
* The **server** asks core for it before recording an attachment. This file has no core to ask —
|
|
233
|
+
* it is data with no client and no imports — so whoever builds the mock passes a resolver in.
|
|
234
|
+
* `api-instance.ts` supplies one backed by the shell's own mock file store, which is where the
|
|
235
|
+
* bytes an upload produced actually went; the default below is for the unit tests, which attach
|
|
236
|
+
* ids that were never uploaded anywhere.
|
|
237
|
+
*/
|
|
238
|
+
export interface MockFileFacts {
|
|
239
|
+
name: string
|
|
240
|
+
mimeType: string | null
|
|
241
|
+
size: number | null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface MockInventoryOptions {
|
|
245
|
+
describeFile?: (fileId: string) => Promise<MockFileFacts>
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function createMockInventoryApi(options: MockInventoryOptions = {}) {
|
|
249
|
+
const describeFile =
|
|
250
|
+
options.describeFile ?? (async () => ({ name: 'Uploaded file', mimeType: null, size: null }))
|
|
251
|
+
/**
|
|
252
|
+
* Four id sequences, and separate tag numbering, because they were one and it showed.
|
|
253
|
+
*
|
|
254
|
+
* `blank()` incremented the id counter and `create` then formatted a code out of it, so six seeds
|
|
255
|
+
* consumed six numbers and the seventh asset somebody added in a demo was filed as `INV-0013`
|
|
256
|
+
* beside `INV-0006`. An id and a human-readable tag are separate sequences on the server too.
|
|
257
|
+
*
|
|
258
|
+
* The four entity namespaces differ in the *third* group rather than the last, so no asset id can
|
|
259
|
+
* ever equal a category id, a period id — or one of the shell's mock user ids, which live in the
|
|
260
|
+
* same `…-8000-…` space and would otherwise collide with the first asset.
|
|
261
|
+
*/
|
|
262
|
+
const counters = { asset: 1, category: 1, period: 1, history: 1, repair: 1, attachment: 1 }
|
|
263
|
+
const newId = (kind: keyof typeof counters, group: string) =>
|
|
264
|
+
`01920000-0000-7000-${group}-${String(counters[kind]++).padStart(12, '0')}`
|
|
265
|
+
const assetId = () => newId('asset', '8001')
|
|
266
|
+
const categoryId = () => newId('category', '8002')
|
|
267
|
+
const periodId = () => newId('period', '8003')
|
|
268
|
+
const historyId = () => newId('history', '8004')
|
|
269
|
+
const repairId = () => newId('repair', '8005')
|
|
270
|
+
const attachmentId = () => newId('attachment', '8006')
|
|
271
|
+
|
|
272
|
+
let nextCodeNumber = SEEDS.length + 1
|
|
273
|
+
const pad = (n: number) => `INV-${String(n).padStart(4, '0')}`
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The workspace every seeded row belongs to, learned from the first call that names one.
|
|
277
|
+
*
|
|
278
|
+
* The seeds are built before any workspace exists, and only `list` used to stamp its argument
|
|
279
|
+
* onto what it returned — so the same asset came back with a real `workspaceId` from the list and
|
|
280
|
+
* an empty one from `get`, `update` and `archive`. A demo where one row disagrees with itself
|
|
281
|
+
* about which workspace it is in is the kind of thing nobody notices until a screen keys a cache
|
|
282
|
+
* off it.
|
|
283
|
+
*/
|
|
284
|
+
let workspace = '' as Asset['workspaceId']
|
|
285
|
+
const remember = (id?: string) => {
|
|
286
|
+
if (id) workspace = id as Asset['workspaceId']
|
|
287
|
+
}
|
|
288
|
+
const stamp = <T extends { workspaceId: Asset['workspaceId'] }>(row: T): T => ({
|
|
289
|
+
...row,
|
|
290
|
+
workspaceId: workspace,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------- categories
|
|
294
|
+
|
|
295
|
+
const categories: Category[] = CATEGORY_SEEDS.map((seed) => ({
|
|
296
|
+
id: categoryId(),
|
|
297
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
298
|
+
name: seed.name,
|
|
299
|
+
order: seed.order,
|
|
300
|
+
createdAt: daysAgo(60),
|
|
301
|
+
updatedAt: daysAgo(60),
|
|
302
|
+
archivedAt: seed.archived ? daysAgo(10) : null,
|
|
303
|
+
}))
|
|
304
|
+
const categoryByKey = new Map(CATEGORY_SEEDS.map((seed, i) => [seed.key, categories[i]!.id]))
|
|
305
|
+
|
|
306
|
+
// -------------------------------------------------------------------------------- assets
|
|
307
|
+
|
|
308
|
+
function blank(code: string, seed: Partial<Seed> & { name?: string } = {}): Asset {
|
|
22
309
|
const now = new Date().toISOString()
|
|
23
310
|
return {
|
|
24
|
-
id,
|
|
311
|
+
id: assetId(),
|
|
25
312
|
workspaceId: '' as Asset['workspaceId'],
|
|
26
|
-
code,
|
|
27
|
-
name,
|
|
313
|
+
code: seed.code ?? code,
|
|
314
|
+
name: seed.name ?? '',
|
|
28
315
|
description: '',
|
|
29
|
-
categoryId: null,
|
|
30
|
-
status,
|
|
31
|
-
custodianUserId: null,
|
|
32
|
-
custodySince: null,
|
|
33
|
-
serialNumber: null,
|
|
34
|
-
location: null,
|
|
316
|
+
categoryId: seed.categoryKey ? (categoryByKey.get(seed.categoryKey) ?? null) : null,
|
|
317
|
+
status: seed.status ?? 'in_stock',
|
|
318
|
+
custodianUserId: seed.custodian ?? null,
|
|
319
|
+
custodySince: seed.custodian ? daysAgo(21) : null,
|
|
320
|
+
serialNumber: seed.serialNumber ?? null,
|
|
321
|
+
location: seed.location ?? null,
|
|
35
322
|
purchasedOn: null,
|
|
36
323
|
purchasedFrom: null,
|
|
37
324
|
priceMinor: null,
|
|
38
325
|
currency: null,
|
|
39
|
-
warrantyUntil: null,
|
|
326
|
+
warrantyUntil: seed.warrantyUntil ?? null,
|
|
40
327
|
photoFileId: null,
|
|
328
|
+
custom: {},
|
|
41
329
|
createdAt: now,
|
|
42
330
|
updatedAt: now,
|
|
43
|
-
archivedAt: null,
|
|
331
|
+
archivedAt: seed.archived ? now : null,
|
|
44
332
|
}
|
|
45
333
|
}
|
|
46
334
|
|
|
335
|
+
const assets: Asset[] = SEEDS.map((seed, i) => blank(pad(i + 1), seed))
|
|
336
|
+
|
|
337
|
+
// ------------------------------------------------------------------- custody and history
|
|
338
|
+
|
|
339
|
+
const periods: CustodyPeriod[] = []
|
|
340
|
+
const history: AssetHistoryEntry[] = []
|
|
341
|
+
|
|
342
|
+
const appendHistory = (
|
|
343
|
+
asset: Asset,
|
|
344
|
+
action: string,
|
|
345
|
+
opts: {
|
|
346
|
+
actorId?: string | null
|
|
347
|
+
changes?: AssetHistoryEntry['changes']
|
|
348
|
+
data?: Record<string, unknown>
|
|
349
|
+
occurredAt?: string
|
|
350
|
+
} = {},
|
|
351
|
+
): AssetHistoryEntry => {
|
|
352
|
+
const entry: AssetHistoryEntry = {
|
|
353
|
+
id: historyId(),
|
|
354
|
+
assetId: asset.id,
|
|
355
|
+
actorId: opts.actorId === undefined ? PEOPLE.maya : opts.actorId,
|
|
356
|
+
action,
|
|
357
|
+
changes: opts.changes ?? [],
|
|
358
|
+
data: opts.data ?? {},
|
|
359
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
360
|
+
}
|
|
361
|
+
history.push(entry)
|
|
362
|
+
return entry
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* A seeded past, so the timeline and "previous holders" have something to draw.
|
|
367
|
+
*
|
|
368
|
+
* INV-0001 has been round the houses on purpose — created, handed to Maya, handed on to Dan, and
|
|
369
|
+
* edited — because that is the one asset a demo opens, and an empty Custody tab beside an empty
|
|
370
|
+
* History tab shows neither feature.
|
|
371
|
+
*/
|
|
372
|
+
for (const asset of assets) {
|
|
373
|
+
appendHistory(asset, 'created', { occurredAt: daysAgo(45) })
|
|
374
|
+
}
|
|
375
|
+
{
|
|
376
|
+
const first = assets[0]!
|
|
377
|
+
const closed: CustodyPeriod = {
|
|
378
|
+
id: periodId(),
|
|
379
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
380
|
+
assetId: first.id,
|
|
381
|
+
userId: PEOPLE.maya,
|
|
382
|
+
note: 'For the Berlin trip',
|
|
383
|
+
effectiveFrom: daysAgo(40),
|
|
384
|
+
effectiveTo: daysAgo(21),
|
|
385
|
+
createdBy: PEOPLE.maya,
|
|
386
|
+
createdAt: daysAgo(40),
|
|
387
|
+
}
|
|
388
|
+
periods.push(closed)
|
|
389
|
+
appendHistory(first, 'assigned', {
|
|
390
|
+
occurredAt: daysAgo(40),
|
|
391
|
+
data: { userId: PEOPLE.maya, note: closed.note },
|
|
392
|
+
})
|
|
393
|
+
appendHistory(first, 'updated', {
|
|
394
|
+
occurredAt: daysAgo(30),
|
|
395
|
+
changes: [{ field: 'warrantyUntil', from: '2026-03-14', to: '2027-03-14' }],
|
|
396
|
+
})
|
|
397
|
+
appendHistory(first, 'transferred', {
|
|
398
|
+
occurredAt: daysAgo(21),
|
|
399
|
+
data: { userId: PEOPLE.dan, previousUserId: PEOPLE.maya },
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
for (const asset of assets) {
|
|
403
|
+
if (!asset.custodianUserId) continue
|
|
404
|
+
periods.push({
|
|
405
|
+
id: periodId(),
|
|
406
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
407
|
+
assetId: asset.id,
|
|
408
|
+
userId: asset.custodianUserId,
|
|
409
|
+
note: null,
|
|
410
|
+
effectiveFrom: asset.custodySince ?? daysAgo(21),
|
|
411
|
+
effectiveTo: null,
|
|
412
|
+
createdBy: PEOPLE.maya,
|
|
413
|
+
createdAt: asset.custodySince ?? daysAgo(21),
|
|
414
|
+
})
|
|
415
|
+
if (asset !== assets[0])
|
|
416
|
+
appendHistory(asset, 'assigned', {
|
|
417
|
+
occurredAt: asset.custodySince ?? daysAgo(21),
|
|
418
|
+
data: { userId: asset.custodianUserId },
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --------------------------------------------------------------- repairs and attachments
|
|
423
|
+
|
|
424
|
+
const repairs: Repair[] = []
|
|
425
|
+
const attachments: Attachment[] = []
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* This file's copy of the server's `deriveStatus`, and it has to stay a copy of it.
|
|
429
|
+
*
|
|
430
|
+
* Repair wins the status column, custody keeps the custodian column, and neither cancels the
|
|
431
|
+
* other — a laptop at the repairer is still whoever's it was. A mock that wrote `assigned`
|
|
432
|
+
* unconditionally after a handover would show a demo audience an item as back in the office while
|
|
433
|
+
* its repair card said it was in a workshop.
|
|
434
|
+
*/
|
|
435
|
+
const statusFor = (asset: Asset): AssetStatus => {
|
|
436
|
+
if (repairs.some((repair) => repair.assetId === asset.id && repair.returnedOn === null))
|
|
437
|
+
return 'under_repair'
|
|
438
|
+
return asset.custodianUserId ? 'assigned' : 'in_stock'
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const restamp = (asset: Asset) => {
|
|
442
|
+
asset.status = statusFor(asset)
|
|
443
|
+
asset.updatedAt = new Date().toISOString()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
{
|
|
447
|
+
/**
|
|
448
|
+
* A seeded repair for the one asset the seeds call `under_repair`, and a finished one for the
|
|
449
|
+
* asset a demo opens.
|
|
450
|
+
*
|
|
451
|
+
* Without the open row, INV-0003 would carry a status nothing in the demo could explain — a
|
|
452
|
+
* status column and a Repairs tab disagreeing about the same item is exactly the thing this
|
|
453
|
+
* file exists to prevent.
|
|
454
|
+
*/
|
|
455
|
+
const away = assets[2]!
|
|
456
|
+
repairs.push({
|
|
457
|
+
id: repairId(),
|
|
458
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
459
|
+
assetId: away.id,
|
|
460
|
+
summary: 'Cracked screen',
|
|
461
|
+
detail: null,
|
|
462
|
+
vendor: 'Kadıköy Teknik',
|
|
463
|
+
costMinor: null,
|
|
464
|
+
currency: null,
|
|
465
|
+
sentOn: dateDaysAgo(6),
|
|
466
|
+
returnedOn: null,
|
|
467
|
+
createdBy: PEOPLE.maya,
|
|
468
|
+
createdAt: daysAgo(6),
|
|
469
|
+
updatedAt: daysAgo(6),
|
|
470
|
+
})
|
|
471
|
+
appendHistory(away, 'repair_logged', {
|
|
472
|
+
occurredAt: daysAgo(6),
|
|
473
|
+
data: { repairId: repairs[0]!.id, summary: 'Cracked screen', vendor: 'Kadıköy Teknik' },
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
const first = assets[0]!
|
|
477
|
+
const past: Repair = {
|
|
478
|
+
id: repairId(),
|
|
479
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
480
|
+
assetId: first.id,
|
|
481
|
+
summary: 'Battery replacement',
|
|
482
|
+
detail: 'Swelling under the trackpad.',
|
|
483
|
+
vendor: 'Apple Authorised Service',
|
|
484
|
+
costMinor: 18900,
|
|
485
|
+
currency: 'EUR',
|
|
486
|
+
sentOn: dateDaysAgo(34),
|
|
487
|
+
returnedOn: dateDaysAgo(27),
|
|
488
|
+
createdBy: PEOPLE.maya,
|
|
489
|
+
createdAt: daysAgo(34),
|
|
490
|
+
updatedAt: daysAgo(27),
|
|
491
|
+
}
|
|
492
|
+
repairs.push(past)
|
|
493
|
+
appendHistory(first, 'repair_logged', {
|
|
494
|
+
occurredAt: daysAgo(34),
|
|
495
|
+
data: { repairId: past.id, summary: past.summary, vendor: past.vendor },
|
|
496
|
+
})
|
|
497
|
+
appendHistory(first, 'repair_completed', {
|
|
498
|
+
occurredAt: daysAgo(27),
|
|
499
|
+
data: { repairId: past.id, summary: past.summary, costMinor: 18900, currency: 'EUR' },
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
// Two files: the asset's own receipt, and the invoice for the repair above. The pair is what
|
|
503
|
+
// shows that a repair keeps its own paperwork rather than dropping it in with everything else.
|
|
504
|
+
attachments.push({
|
|
505
|
+
id: attachmentId(),
|
|
506
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
507
|
+
assetId: first.id,
|
|
508
|
+
repairId: null,
|
|
509
|
+
fileId: '01920000-0000-7000-8007-000000000001',
|
|
510
|
+
name: 'Purchase receipt.pdf',
|
|
511
|
+
mimeType: 'application/pdf',
|
|
512
|
+
size: 184_320,
|
|
513
|
+
uploadedBy: PEOPLE.maya,
|
|
514
|
+
createdAt: daysAgo(45),
|
|
515
|
+
})
|
|
516
|
+
attachments.push({
|
|
517
|
+
id: attachmentId(),
|
|
518
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
519
|
+
assetId: first.id,
|
|
520
|
+
repairId: past.id,
|
|
521
|
+
fileId: '01920000-0000-7000-8007-000000000002',
|
|
522
|
+
name: 'Repair invoice.pdf',
|
|
523
|
+
mimeType: 'application/pdf',
|
|
524
|
+
size: 96_140,
|
|
525
|
+
uploadedBy: PEOPLE.maya,
|
|
526
|
+
createdAt: daysAgo(27),
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// -------------------------------------------------------------------------------- helpers
|
|
531
|
+
|
|
532
|
+
const matches = (a: Asset, q?: string) =>
|
|
533
|
+
!q ||
|
|
534
|
+
[a.name, a.code, a.serialNumber ?? ''].some((field) => field.toLowerCase().includes(q.toLowerCase()))
|
|
535
|
+
|
|
536
|
+
const find = (id: string) => {
|
|
537
|
+
const asset = assets.find((a) => a.id === id)
|
|
538
|
+
if (!asset) throw new MockApiError('NOT_FOUND', 'Asset not found')
|
|
539
|
+
return asset
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const findCategory = (id: string) => {
|
|
543
|
+
const category = categories.find((c) => c.id === id)
|
|
544
|
+
if (!category) throw new MockApiError('NOT_FOUND', 'Category not found')
|
|
545
|
+
return category
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const openPeriodFor = (id: string) =>
|
|
549
|
+
periods.find((period) => period.assetId === id && period.effectiveTo === null)
|
|
550
|
+
|
|
551
|
+
/** At most one, exactly as `inventory_repairs_one_open_uq` guarantees on the server. */
|
|
552
|
+
const openRepairFor = (id: string) =>
|
|
553
|
+
repairs.find((repair) => repair.assetId === id && repair.returnedOn === null)
|
|
554
|
+
|
|
555
|
+
const findRepair = (id: string) => {
|
|
556
|
+
const repair = repairs.find((row) => row.id === id)
|
|
557
|
+
if (!repair) throw new MockApiError('NOT_FOUND', 'Repair not found')
|
|
558
|
+
return repair
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Today as a plain date, which is what the server fills in when a caller sends none. */
|
|
562
|
+
const today = () => new Date().toISOString().slice(0, 10)
|
|
563
|
+
|
|
564
|
+
/** The one refusal a live asset can produce before any of the three verbs runs. */
|
|
565
|
+
const liveAsset = (id: string) => {
|
|
566
|
+
const asset = find(id)
|
|
567
|
+
if (asset.archivedAt)
|
|
568
|
+
throw new MockApiError(
|
|
569
|
+
'CONFLICT',
|
|
570
|
+
'This item is archived. Restore it before handing it over.',
|
|
571
|
+
'inventory.custody.archived',
|
|
572
|
+
)
|
|
573
|
+
return asset
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const listAssets = ({
|
|
577
|
+
q,
|
|
578
|
+
status,
|
|
579
|
+
categoryId: category,
|
|
580
|
+
custodianUserId,
|
|
581
|
+
archived = false,
|
|
582
|
+
sort = 'recent',
|
|
583
|
+
limit = 50,
|
|
584
|
+
cursor,
|
|
585
|
+
}: {
|
|
586
|
+
q?: string
|
|
587
|
+
status?: AssetStatus
|
|
588
|
+
categoryId?: string
|
|
589
|
+
custodianUserId?: string
|
|
590
|
+
archived?: boolean
|
|
591
|
+
sort?: AssetSort
|
|
592
|
+
limit?: number
|
|
593
|
+
cursor?: string
|
|
594
|
+
}) => {
|
|
595
|
+
// Every control the screen offers filters here, or the demo argues with its own toolbar.
|
|
596
|
+
const all = assets
|
|
597
|
+
.filter((a) => matches(a, q))
|
|
598
|
+
.filter((a) => (status ? a.status === status : true))
|
|
599
|
+
.filter((a) => (category ? a.categoryId === category : true))
|
|
600
|
+
.filter((a) => (custodianUserId ? a.custodianUserId === custodianUserId : true))
|
|
601
|
+
.filter((a) => (archived ? true : !a.archivedAt))
|
|
602
|
+
.map(stamp)
|
|
603
|
+
|
|
604
|
+
// `recent` is the only descending order, and it is the contract's default — the list opens
|
|
605
|
+
// on what was added last, which is what the server does and what the old mock did not.
|
|
606
|
+
const descending = sort === 'recent'
|
|
607
|
+
all.sort((a, b) => (descending ? -compare(a, b, sort) : compare(a, b, sort)))
|
|
608
|
+
|
|
609
|
+
let rows = all
|
|
610
|
+
if (cursor) {
|
|
611
|
+
const mark = decodeCursor(cursor, sort)
|
|
612
|
+
const bookmarked = assets.find((a) => a.id === mark.i)
|
|
613
|
+
/**
|
|
614
|
+
* A row archived or removed between two pages leaves nothing to compare against and the
|
|
615
|
+
* page simply ends — the server's deliberate choice, because somebody else editing while
|
|
616
|
+
* you read is an ordinary race rather than a malformed request.
|
|
617
|
+
*/
|
|
618
|
+
if (!bookmarked) rows = []
|
|
619
|
+
else
|
|
620
|
+
rows = all.filter((a) => {
|
|
621
|
+
const order = compare(a, bookmarked, sort)
|
|
622
|
+
return descending ? order < 0 : order > 0
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// limit + 1 to learn whether there is a next page, exactly as the server does.
|
|
627
|
+
const window = rows.slice(0, limit + 1)
|
|
628
|
+
const items = window.slice(0, limit)
|
|
629
|
+
const last = items.at(-1)
|
|
630
|
+
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: sort }) : null
|
|
631
|
+
return { items, nextCursor }
|
|
632
|
+
}
|
|
633
|
+
|
|
47
634
|
return {
|
|
48
635
|
assets: {
|
|
49
|
-
list: async ({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
636
|
+
list: async ({
|
|
637
|
+
workspaceId,
|
|
638
|
+
...rest
|
|
639
|
+
}: {
|
|
640
|
+
workspaceId: string
|
|
641
|
+
q?: string
|
|
642
|
+
status?: AssetStatus
|
|
643
|
+
categoryId?: string
|
|
644
|
+
custodianUserId?: string
|
|
645
|
+
archived?: boolean
|
|
646
|
+
sort?: AssetSort
|
|
647
|
+
limit?: number
|
|
648
|
+
cursor?: string
|
|
649
|
+
}) => {
|
|
650
|
+
remember(workspaceId)
|
|
651
|
+
return listAssets(rest)
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
get: async ({ workspaceId, assetId: id }: { workspaceId?: string; assetId: string }) => {
|
|
655
|
+
remember(workspaceId)
|
|
656
|
+
return stamp(find(id))
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* The timeline, newest first, paged by **id** — the server's ordering, for the server's
|
|
661
|
+
* reason: an id already carries the clock and is unique where a timestamp two rows written in
|
|
662
|
+
* one transaction share is not.
|
|
663
|
+
*/
|
|
664
|
+
history: async ({
|
|
665
|
+
workspaceId,
|
|
666
|
+
assetId: id,
|
|
667
|
+
limit = 50,
|
|
668
|
+
cursor,
|
|
669
|
+
}: {
|
|
670
|
+
workspaceId?: string
|
|
671
|
+
assetId: string
|
|
672
|
+
limit?: number
|
|
673
|
+
cursor?: string
|
|
674
|
+
}) => {
|
|
675
|
+
remember(workspaceId)
|
|
676
|
+
find(id)
|
|
677
|
+
const all = history.filter((entry) => entry.assetId === id).sort((a, b) => (a.id < b.id ? 1 : -1))
|
|
678
|
+
const rows = cursor ? all.filter((entry) => entry.id < decodeCursor(cursor, 'recent').i) : all
|
|
679
|
+
const window = rows.slice(0, limit + 1)
|
|
680
|
+
const items = window.slice(0, limit)
|
|
681
|
+
const last = items.at(-1)
|
|
682
|
+
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
|
|
683
|
+
return { items, nextCursor }
|
|
59
684
|
},
|
|
685
|
+
|
|
60
686
|
create: async ({ workspaceId, ...rest }: { workspaceId: string } & Record<string, unknown>) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
String(rest.name),
|
|
66
|
-
'in_stock',
|
|
67
|
-
),
|
|
68
|
-
workspaceId: workspaceId as Asset['workspaceId'],
|
|
687
|
+
remember(workspaceId)
|
|
688
|
+
const asset: Asset = {
|
|
689
|
+
...blank(pad(nextCodeNumber++)),
|
|
690
|
+
name: String(rest.name ?? ''),
|
|
69
691
|
description: String(rest.description ?? ''),
|
|
692
|
+
categoryId: (rest.categoryId as string | undefined) ?? null,
|
|
70
693
|
serialNumber: (rest.serialNumber as string | undefined) ?? null,
|
|
71
694
|
location: (rest.location as string | undefined) ?? null,
|
|
72
695
|
purchasedFrom: (rest.purchasedFrom as string | undefined) ?? null,
|
|
@@ -75,20 +698,606 @@ export function createMockInventoryApi() {
|
|
|
75
698
|
priceMinor: (rest.priceMinor as number | undefined) ?? null,
|
|
76
699
|
currency: (rest.currency as string | undefined) ?? null,
|
|
77
700
|
}
|
|
78
|
-
assets.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
701
|
+
assets.push(asset)
|
|
702
|
+
appendHistory(asset, 'created')
|
|
703
|
+
return stamp(asset)
|
|
704
|
+
},
|
|
705
|
+
|
|
706
|
+
update: async ({
|
|
707
|
+
assetId: id,
|
|
708
|
+
workspaceId,
|
|
709
|
+
...rest
|
|
710
|
+
}: { assetId: string; workspaceId?: string } & Record<string, unknown>) => {
|
|
711
|
+
remember(workspaceId)
|
|
712
|
+
const asset = find(id)
|
|
713
|
+
// The diff before the assignment, so the timeline records what actually moved — the server
|
|
714
|
+
// does the same and for the same reason: an update that changed nothing writes nothing.
|
|
715
|
+
const changes = Object.entries(rest)
|
|
716
|
+
.filter(([field, value]) => (asset as Record<string, unknown>)[field] !== value)
|
|
717
|
+
.map(([field, value]) => ({ field, from: (asset as Record<string, unknown>)[field], to: value }))
|
|
718
|
+
// `workspaceId` is not among the fields assigned: it is routing, not a field of the asset,
|
|
719
|
+
// and letting a patch carry it means a demo can move a row to a workspace that is not real.
|
|
720
|
+
Object.assign(asset, rest, { updatedAt: new Date().toISOString() })
|
|
721
|
+
if (changes.length) appendHistory(asset, 'updated', { changes })
|
|
722
|
+
return stamp(asset)
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
archive: async ({
|
|
726
|
+
workspaceId,
|
|
727
|
+
assetId: id,
|
|
728
|
+
archived,
|
|
729
|
+
}: {
|
|
730
|
+
workspaceId?: string
|
|
731
|
+
assetId: string
|
|
732
|
+
archived?: boolean
|
|
733
|
+
}) => {
|
|
734
|
+
remember(workspaceId)
|
|
735
|
+
const asset = find(id)
|
|
736
|
+
const archiving = archived !== false
|
|
737
|
+
// Somebody is still answerable for a held item; taking it out of the register does not
|
|
738
|
+
// change that, it only stops anybody being able to find out. The server refuses this too.
|
|
739
|
+
if (archiving && asset.custodianUserId)
|
|
740
|
+
throw new MockApiError(
|
|
741
|
+
'CONFLICT',
|
|
742
|
+
'Somebody is still holding this item. Take it back before archiving it.',
|
|
743
|
+
'inventory.asset.still_held',
|
|
744
|
+
)
|
|
745
|
+
// And an item at a repairer cannot leave the register either: money is committed and the
|
|
746
|
+
// thing is out of the building. The server refuses this for the same reason.
|
|
747
|
+
if (archiving && openRepairFor(id))
|
|
748
|
+
throw new MockApiError(
|
|
749
|
+
'CONFLICT',
|
|
750
|
+
'This item is away for repair. Log the repair as returned before archiving it.',
|
|
751
|
+
'inventory.asset.under_repair',
|
|
752
|
+
)
|
|
753
|
+
asset.archivedAt = archiving ? new Date().toISOString() : null
|
|
754
|
+
asset.updatedAt = new Date().toISOString()
|
|
755
|
+
appendHistory(asset, archiving ? 'retired' : 'restored')
|
|
756
|
+
return stamp(asset)
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
categories: {
|
|
761
|
+
list: async ({ workspaceId, archived = false }: { workspaceId: string; archived?: boolean }) => {
|
|
762
|
+
remember(workspaceId)
|
|
763
|
+
return categories
|
|
764
|
+
.filter((category) => (archived ? true : !category.archivedAt))
|
|
765
|
+
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
|
766
|
+
.map(stamp)
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
create: async ({
|
|
770
|
+
workspaceId,
|
|
771
|
+
name,
|
|
772
|
+
order = 0,
|
|
773
|
+
}: {
|
|
774
|
+
workspaceId: string
|
|
775
|
+
name: string
|
|
776
|
+
order?: number
|
|
777
|
+
}) => {
|
|
778
|
+
remember(workspaceId)
|
|
779
|
+
// The server's unique index is what actually decides, and it answers a duplicate with a
|
|
780
|
+
// sentence rather than a 500. The demo answers the same way.
|
|
781
|
+
if (categories.some((category) => category.name === name))
|
|
782
|
+
throw new MockApiError(
|
|
783
|
+
'CONFLICT',
|
|
784
|
+
`This workspace already has a category called “${name}”.`,
|
|
785
|
+
'inventory.category.name_taken',
|
|
786
|
+
)
|
|
787
|
+
const now = new Date().toISOString()
|
|
788
|
+
const category: Category = {
|
|
789
|
+
id: categoryId(),
|
|
790
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
791
|
+
name,
|
|
792
|
+
order,
|
|
793
|
+
createdAt: now,
|
|
794
|
+
updatedAt: now,
|
|
795
|
+
archivedAt: null,
|
|
796
|
+
}
|
|
797
|
+
categories.push(category)
|
|
798
|
+
return stamp(category)
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
update: async ({
|
|
802
|
+
workspaceId,
|
|
803
|
+
categoryId: id,
|
|
804
|
+
...rest
|
|
805
|
+
}: {
|
|
806
|
+
workspaceId?: string
|
|
807
|
+
categoryId: string
|
|
808
|
+
name?: string
|
|
809
|
+
order?: number
|
|
810
|
+
}) => {
|
|
811
|
+
remember(workspaceId)
|
|
812
|
+
const category = findCategory(id)
|
|
813
|
+
if (rest.name && categories.some((c) => c.id !== id && c.name === rest.name))
|
|
814
|
+
throw new MockApiError(
|
|
815
|
+
'CONFLICT',
|
|
816
|
+
`This workspace already has a category called “${rest.name}”.`,
|
|
817
|
+
'inventory.category.name_taken',
|
|
818
|
+
)
|
|
819
|
+
Object.assign(category, rest, { updatedAt: new Date().toISOString() })
|
|
820
|
+
return stamp(category)
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
archive: async ({
|
|
824
|
+
workspaceId,
|
|
825
|
+
categoryId: id,
|
|
826
|
+
archived,
|
|
827
|
+
}: {
|
|
828
|
+
workspaceId?: string
|
|
829
|
+
categoryId: string
|
|
830
|
+
archived?: boolean
|
|
831
|
+
}) => {
|
|
832
|
+
remember(workspaceId)
|
|
833
|
+
const category = findCategory(id)
|
|
834
|
+
// Nothing deletes: an asset filed under this category keeps naming it.
|
|
835
|
+
category.archivedAt = archived === false ? null : new Date().toISOString()
|
|
836
|
+
category.updatedAt = new Date().toISOString()
|
|
837
|
+
return stamp(category)
|
|
838
|
+
},
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The same invariant the server keeps, in one place rather than three.
|
|
843
|
+
*
|
|
844
|
+
* Each verb closes what has to close, opens what has to open, brings the three denormalised
|
|
845
|
+
* columns on the asset into step and appends a history row. `transfer` does it in one step
|
|
846
|
+
* rather than as a return followed by an assign, because two steps would leave the asset in
|
|
847
|
+
* stock in between — visible in the list, and permanently visible in the timeline as a return
|
|
848
|
+
* nobody performed.
|
|
849
|
+
*/
|
|
850
|
+
custody: {
|
|
851
|
+
history: async ({
|
|
852
|
+
workspaceId,
|
|
853
|
+
assetId: id,
|
|
854
|
+
limit = 100,
|
|
855
|
+
}: {
|
|
856
|
+
workspaceId?: string
|
|
857
|
+
assetId: string
|
|
858
|
+
limit?: number
|
|
859
|
+
}) => {
|
|
860
|
+
remember(workspaceId)
|
|
861
|
+
find(id)
|
|
862
|
+
return (
|
|
863
|
+
periods
|
|
864
|
+
.filter((period) => period.assetId === id)
|
|
865
|
+
/**
|
|
866
|
+
* `effective_from` and then the id, which is the server's ordering and needs both.
|
|
867
|
+
*
|
|
868
|
+
* A hand-on closes one period and opens another *at the same instant* — that is what
|
|
869
|
+
* makes them abut — so the timestamps are equal and there is no order between the two
|
|
870
|
+
* rows without the id. Sorting on the timestamp alone showed the handover before the
|
|
871
|
+
* return it replaced, at random, and the panel's "who had this before me" list opened
|
|
872
|
+
* with the row that is still open.
|
|
873
|
+
*/
|
|
874
|
+
.sort((a, b) => {
|
|
875
|
+
if (a.effectiveFrom !== b.effectiveFrom) return a.effectiveFrom < b.effectiveFrom ? 1 : -1
|
|
876
|
+
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0
|
|
877
|
+
})
|
|
878
|
+
.slice(0, limit)
|
|
879
|
+
.map(stamp)
|
|
880
|
+
)
|
|
881
|
+
},
|
|
882
|
+
|
|
883
|
+
byUser: async ({
|
|
884
|
+
workspaceId,
|
|
885
|
+
userId,
|
|
886
|
+
limit = 50,
|
|
887
|
+
cursor,
|
|
888
|
+
}: {
|
|
889
|
+
workspaceId: string
|
|
890
|
+
userId: string
|
|
891
|
+
limit?: number
|
|
892
|
+
cursor?: string
|
|
893
|
+
}) => {
|
|
894
|
+
remember(workspaceId)
|
|
895
|
+
return listAssets({ custodianUserId: userId, archived: false, sort: 'recent', limit, cursor })
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
assign: async ({
|
|
899
|
+
workspaceId,
|
|
900
|
+
assetId: id,
|
|
901
|
+
userId,
|
|
902
|
+
note,
|
|
903
|
+
}: {
|
|
904
|
+
workspaceId: string
|
|
905
|
+
assetId: string
|
|
906
|
+
userId: string
|
|
907
|
+
note?: string | null
|
|
908
|
+
}) => {
|
|
909
|
+
remember(workspaceId)
|
|
910
|
+
const asset = liveAsset(id)
|
|
911
|
+
const open = openPeriodFor(id)
|
|
912
|
+
if (open)
|
|
913
|
+
throw new MockApiError(
|
|
914
|
+
'CONFLICT',
|
|
915
|
+
open.userId === userId
|
|
916
|
+
? 'They are already holding this item.'
|
|
917
|
+
: 'Somebody else is holding this item. Hand it on, or take it back first.',
|
|
918
|
+
'inventory.custody.already_held',
|
|
919
|
+
)
|
|
920
|
+
const at = new Date().toISOString()
|
|
921
|
+
const period: CustodyPeriod = {
|
|
922
|
+
id: periodId(),
|
|
923
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
924
|
+
assetId: id,
|
|
925
|
+
userId,
|
|
926
|
+
note: note ?? null,
|
|
927
|
+
effectiveFrom: at,
|
|
928
|
+
effectiveTo: null,
|
|
929
|
+
createdBy: PEOPLE.maya,
|
|
930
|
+
createdAt: at,
|
|
931
|
+
}
|
|
932
|
+
periods.push(period)
|
|
933
|
+
// The status is derived, never assumed: an item at a repairer stays `under_repair` when it
|
|
934
|
+
// changes hands, because a repair does not release whoever is answerable for it.
|
|
935
|
+
Object.assign(asset, { custodianUserId: userId, custodySince: at })
|
|
936
|
+
restamp(asset)
|
|
937
|
+
appendHistory(asset, 'assigned', { data: { userId, ...(note ? { note } : {}) } })
|
|
938
|
+
return { asset: stamp(asset), period: stamp(period) }
|
|
939
|
+
},
|
|
940
|
+
|
|
941
|
+
transfer: async ({
|
|
942
|
+
workspaceId,
|
|
943
|
+
assetId: id,
|
|
944
|
+
userId,
|
|
945
|
+
note,
|
|
946
|
+
}: {
|
|
947
|
+
workspaceId: string
|
|
948
|
+
assetId: string
|
|
949
|
+
userId: string
|
|
950
|
+
note?: string | null
|
|
951
|
+
}) => {
|
|
952
|
+
remember(workspaceId)
|
|
953
|
+
const asset = liveAsset(id)
|
|
954
|
+
const open = openPeriodFor(id)
|
|
955
|
+
if (!open)
|
|
956
|
+
throw new MockApiError(
|
|
957
|
+
'CONFLICT',
|
|
958
|
+
'Nobody is holding this item, so there is nothing to hand on. Assign it instead.',
|
|
959
|
+
'inventory.custody.not_held',
|
|
960
|
+
)
|
|
961
|
+
if (open.userId === userId)
|
|
962
|
+
throw new MockApiError(
|
|
963
|
+
'CONFLICT',
|
|
964
|
+
'They are already holding this item.',
|
|
965
|
+
'inventory.custody.already_held',
|
|
966
|
+
)
|
|
967
|
+
// One instant for both halves, so the closing period and the opening one abut exactly and
|
|
968
|
+
// the asset is never held by nobody for a microsecond.
|
|
969
|
+
const at = new Date().toISOString()
|
|
970
|
+
open.effectiveTo = at
|
|
971
|
+
const period: CustodyPeriod = {
|
|
972
|
+
id: periodId(),
|
|
973
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
974
|
+
assetId: id,
|
|
975
|
+
userId,
|
|
976
|
+
note: note ?? null,
|
|
977
|
+
effectiveFrom: at,
|
|
978
|
+
effectiveTo: null,
|
|
979
|
+
createdBy: PEOPLE.maya,
|
|
980
|
+
createdAt: at,
|
|
981
|
+
}
|
|
982
|
+
periods.push(period)
|
|
983
|
+
Object.assign(asset, { custodianUserId: userId, custodySince: at })
|
|
984
|
+
restamp(asset)
|
|
985
|
+
appendHistory(asset, 'transferred', {
|
|
986
|
+
data: { userId, previousUserId: open.userId, ...(note ? { note } : {}) },
|
|
987
|
+
})
|
|
988
|
+
return { asset: stamp(asset), period: stamp(period) }
|
|
989
|
+
},
|
|
990
|
+
|
|
991
|
+
return: async ({
|
|
992
|
+
workspaceId,
|
|
993
|
+
assetId: id,
|
|
994
|
+
note,
|
|
995
|
+
}: {
|
|
996
|
+
workspaceId: string
|
|
997
|
+
assetId: string
|
|
998
|
+
note?: string | null
|
|
999
|
+
}) => {
|
|
1000
|
+
remember(workspaceId)
|
|
1001
|
+
const asset = liveAsset(id)
|
|
1002
|
+
const open = openPeriodFor(id)
|
|
1003
|
+
if (!open)
|
|
1004
|
+
throw new MockApiError(
|
|
1005
|
+
'CONFLICT',
|
|
1006
|
+
'Nobody is holding this item, so there is nothing to take back.',
|
|
1007
|
+
'inventory.custody.not_held',
|
|
1008
|
+
)
|
|
1009
|
+
const at = new Date().toISOString()
|
|
1010
|
+
open.effectiveTo = at
|
|
1011
|
+
Object.assign(asset, { custodianUserId: null, custodySince: null })
|
|
1012
|
+
restamp(asset)
|
|
1013
|
+
appendHistory(asset, 'returned', {
|
|
1014
|
+
data: { previousUserId: open.userId, ...(note ? { note } : {}) },
|
|
1015
|
+
})
|
|
1016
|
+
// Null, like the server: something closed and nothing opened.
|
|
1017
|
+
return { asset: stamp(asset), period: null }
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Repairs, with the refusals the server makes and the status rule it keeps.
|
|
1023
|
+
*
|
|
1024
|
+
* A demo that lets you send the same laptop away twice, or that shows an item as back in the
|
|
1025
|
+
* office the moment its repair is logged as finished while somebody still has it, teaches an
|
|
1026
|
+
* audience that the product does those things.
|
|
1027
|
+
*/
|
|
1028
|
+
repairs: {
|
|
1029
|
+
list: async ({
|
|
1030
|
+
workspaceId,
|
|
1031
|
+
assetId: forAsset,
|
|
1032
|
+
open,
|
|
1033
|
+
limit = 50,
|
|
1034
|
+
cursor,
|
|
1035
|
+
}: {
|
|
1036
|
+
workspaceId: string
|
|
1037
|
+
assetId?: string
|
|
1038
|
+
open?: boolean
|
|
1039
|
+
limit?: number
|
|
1040
|
+
cursor?: string
|
|
1041
|
+
}) => {
|
|
1042
|
+
remember(workspaceId)
|
|
1043
|
+
if (forAsset) find(forAsset)
|
|
1044
|
+
const all = repairs
|
|
1045
|
+
.filter((repair) => (forAsset ? repair.assetId === forAsset : true))
|
|
1046
|
+
.filter((repair) =>
|
|
1047
|
+
open === undefined ? true : open ? repair.returnedOn === null : repair.returnedOn !== null,
|
|
1048
|
+
)
|
|
1049
|
+
// Newest logged first, by id — the server's ordering, for the server's reason: an id is
|
|
1050
|
+
// uuidv7 and already carries the clock, where `sent_on` is a date two repairs share.
|
|
1051
|
+
.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
|
1052
|
+
const rows = cursor ? all.filter((repair) => repair.id < decodeCursor(cursor, 'recent').i) : all
|
|
1053
|
+
const window = rows.slice(0, limit + 1)
|
|
1054
|
+
const items = window.slice(0, limit).map((repair) => {
|
|
1055
|
+
const asset = assets.find((row) => row.id === repair.assetId)
|
|
1056
|
+
// Joined, never stored: renaming an asset renames it here at once.
|
|
1057
|
+
return {
|
|
1058
|
+
...stamp(repair),
|
|
1059
|
+
assetCode: asset?.code ?? '',
|
|
1060
|
+
assetName: asset?.name ?? '',
|
|
1061
|
+
}
|
|
1062
|
+
})
|
|
1063
|
+
const last = window.slice(0, limit).at(-1)
|
|
1064
|
+
const nextCursor = window.length > limit && last ? encodeCursor({ i: last.id, s: 'recent' }) : null
|
|
1065
|
+
return { items, nextCursor }
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
create: async ({
|
|
1069
|
+
workspaceId,
|
|
1070
|
+
assetId: id,
|
|
1071
|
+
summary,
|
|
1072
|
+
detail,
|
|
1073
|
+
vendor,
|
|
1074
|
+
costMinor,
|
|
1075
|
+
currency,
|
|
1076
|
+
sentOn,
|
|
1077
|
+
}: {
|
|
1078
|
+
workspaceId: string
|
|
1079
|
+
assetId: string
|
|
1080
|
+
summary: string
|
|
1081
|
+
detail?: string | null
|
|
1082
|
+
vendor?: string | null
|
|
1083
|
+
costMinor?: number | null
|
|
1084
|
+
currency?: string | null
|
|
1085
|
+
sentOn?: string
|
|
1086
|
+
}) => {
|
|
1087
|
+
remember(workspaceId)
|
|
1088
|
+
const asset = find(id)
|
|
1089
|
+
if (asset.archivedAt)
|
|
1090
|
+
throw new MockApiError(
|
|
1091
|
+
'CONFLICT',
|
|
1092
|
+
'This item is archived. Restore it before sending it for repair.',
|
|
1093
|
+
'inventory.repair.archived',
|
|
1094
|
+
)
|
|
1095
|
+
if (openRepairFor(id))
|
|
1096
|
+
throw new MockApiError(
|
|
1097
|
+
'CONFLICT',
|
|
1098
|
+
'This item is already away for repair. Log that one as returned first.',
|
|
1099
|
+
'inventory.repair.already_open',
|
|
1100
|
+
)
|
|
1101
|
+
const now = new Date().toISOString()
|
|
1102
|
+
const repair: Repair = {
|
|
1103
|
+
id: repairId(),
|
|
1104
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
1105
|
+
assetId: id,
|
|
1106
|
+
summary,
|
|
1107
|
+
detail: detail ?? null,
|
|
1108
|
+
vendor: vendor ?? null,
|
|
1109
|
+
costMinor: costMinor ?? null,
|
|
1110
|
+
// A cost with no currency inherits the asset's, exactly as the server does — a number
|
|
1111
|
+
// with no unit is not a cost.
|
|
1112
|
+
currency: currency ?? (costMinor !== null && costMinor !== undefined ? asset.currency : null),
|
|
1113
|
+
sentOn: sentOn ?? today(),
|
|
1114
|
+
returnedOn: null,
|
|
1115
|
+
createdBy: PEOPLE.maya,
|
|
1116
|
+
createdAt: now,
|
|
1117
|
+
updatedAt: now,
|
|
1118
|
+
}
|
|
1119
|
+
repairs.push(repair)
|
|
1120
|
+
restamp(asset)
|
|
1121
|
+
appendHistory(asset, 'repair_logged', {
|
|
1122
|
+
data: { repairId: repair.id, summary, ...(repair.vendor ? { vendor: repair.vendor } : {}) },
|
|
1123
|
+
})
|
|
1124
|
+
return { repair: stamp(repair), asset: stamp(asset) }
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
update: async ({
|
|
1128
|
+
workspaceId,
|
|
1129
|
+
repairId: id,
|
|
1130
|
+
...rest
|
|
1131
|
+
}: {
|
|
1132
|
+
workspaceId: string
|
|
1133
|
+
repairId: string
|
|
1134
|
+
} & Record<string, unknown>) => {
|
|
1135
|
+
remember(workspaceId)
|
|
1136
|
+
const repair = findRepair(id)
|
|
1137
|
+
// `returnedOn` is not patchable on the server: one column decides the derived status, so
|
|
1138
|
+
// exactly one procedure moves it.
|
|
1139
|
+
const { returnedOn: _ignored, ...patch } = rest
|
|
1140
|
+
Object.assign(repair, patch, { updatedAt: new Date().toISOString() })
|
|
1141
|
+
const asset = find(repair.assetId)
|
|
1142
|
+
// Same inheritance rule as `complete` and the server: only when an amount is arriving and
|
|
1143
|
+
// the repair has no currency at all.
|
|
1144
|
+
if (
|
|
1145
|
+
patch.currency === undefined &&
|
|
1146
|
+
repair.currency === null &&
|
|
1147
|
+
patch.costMinor !== undefined &&
|
|
1148
|
+
patch.costMinor !== null
|
|
1149
|
+
)
|
|
1150
|
+
repair.currency = asset.currency
|
|
1151
|
+
return { repair: stamp(repair), asset: stamp(asset) }
|
|
1152
|
+
},
|
|
1153
|
+
|
|
1154
|
+
complete: async ({
|
|
1155
|
+
workspaceId,
|
|
1156
|
+
repairId: id,
|
|
1157
|
+
returnedOn,
|
|
1158
|
+
costMinor,
|
|
1159
|
+
currency,
|
|
1160
|
+
}: {
|
|
1161
|
+
workspaceId: string
|
|
1162
|
+
repairId: string
|
|
1163
|
+
returnedOn?: string
|
|
1164
|
+
costMinor?: number | null
|
|
1165
|
+
currency?: string | null
|
|
1166
|
+
}) => {
|
|
1167
|
+
remember(workspaceId)
|
|
1168
|
+
const repair = findRepair(id)
|
|
1169
|
+
if (repair.returnedOn)
|
|
1170
|
+
throw new MockApiError(
|
|
1171
|
+
'CONFLICT',
|
|
1172
|
+
'This repair is already logged as finished.',
|
|
1173
|
+
'inventory.repair.already_complete',
|
|
1174
|
+
)
|
|
1175
|
+
const back = returnedOn ?? today()
|
|
1176
|
+
if (back < repair.sentOn)
|
|
1177
|
+
throw new MockApiError(
|
|
1178
|
+
'CONFLICT',
|
|
1179
|
+
'A repair cannot come back before it was sent.',
|
|
1180
|
+
'inventory.repair.returned_before_sent',
|
|
1181
|
+
)
|
|
1182
|
+
const asset = find(repair.assetId)
|
|
1183
|
+
repair.returnedOn = back
|
|
1184
|
+
if (costMinor !== undefined) repair.costMinor = costMinor
|
|
1185
|
+
// The server's one inheritance rule: an amount is *arriving* and the repair has no currency
|
|
1186
|
+
// at all. Not "a cost exists", which would give a deliberately cleared currency back.
|
|
1187
|
+
if (currency !== undefined) repair.currency = currency ?? null
|
|
1188
|
+
else if (repair.currency === null && costMinor !== undefined && costMinor !== null)
|
|
1189
|
+
repair.currency = asset.currency
|
|
1190
|
+
repair.updatedAt = new Date().toISOString()
|
|
1191
|
+
// Back to whoever still holds it, or into stock — never blindly into stock.
|
|
1192
|
+
restamp(asset)
|
|
1193
|
+
appendHistory(asset, 'repair_completed', {
|
|
1194
|
+
data: {
|
|
1195
|
+
repairId: repair.id,
|
|
1196
|
+
summary: repair.summary,
|
|
1197
|
+
...(repair.costMinor !== null ? { costMinor: repair.costMinor, currency: repair.currency } : {}),
|
|
1198
|
+
},
|
|
1199
|
+
})
|
|
1200
|
+
return { repair: stamp(repair), asset: stamp(asset) }
|
|
1201
|
+
},
|
|
1202
|
+
},
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Files, recorded against an asset or one of its repairs.
|
|
1206
|
+
*
|
|
1207
|
+
* The bytes went to the shell's own mock file store through `uploadFile`; this only records
|
|
1208
|
+
* that the asset has them. The name comes from `describeFile`, because the real server asks
|
|
1209
|
+
* core for it and this file has no core to ask.
|
|
1210
|
+
*/
|
|
1211
|
+
attachments: {
|
|
1212
|
+
list: async ({ workspaceId, assetId: id }: { workspaceId: string; assetId: string }) => {
|
|
1213
|
+
remember(workspaceId)
|
|
1214
|
+
find(id)
|
|
1215
|
+
return attachments
|
|
1216
|
+
.filter((row) => row.assetId === id)
|
|
1217
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
|
|
1218
|
+
.map(stamp)
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
add: async ({
|
|
1222
|
+
workspaceId,
|
|
1223
|
+
assetId: id,
|
|
1224
|
+
fileIds,
|
|
1225
|
+
repairId: forRepair,
|
|
1226
|
+
}: {
|
|
1227
|
+
workspaceId: string
|
|
1228
|
+
assetId: string
|
|
1229
|
+
fileIds: string[]
|
|
1230
|
+
repairId?: string | null
|
|
1231
|
+
}) => {
|
|
1232
|
+
remember(workspaceId)
|
|
1233
|
+
find(id)
|
|
1234
|
+
if (forRepair) findRepair(forRepair)
|
|
1235
|
+
const added: Attachment[] = []
|
|
1236
|
+
for (const fileId of [...new Set(fileIds)]) {
|
|
1237
|
+
// The same file twice on one asset adds nothing rather than failing, exactly as the
|
|
1238
|
+
// server's `on conflict do nothing` does.
|
|
1239
|
+
if (attachments.some((row) => row.assetId === id && row.fileId === fileId)) continue
|
|
1240
|
+
const facts = await describeFile(fileId)
|
|
1241
|
+
const row: Attachment = {
|
|
1242
|
+
id: attachmentId(),
|
|
1243
|
+
workspaceId: '' as Asset['workspaceId'],
|
|
1244
|
+
assetId: id,
|
|
1245
|
+
repairId: forRepair ?? null,
|
|
1246
|
+
fileId,
|
|
1247
|
+
name: facts.name,
|
|
1248
|
+
mimeType: facts.mimeType,
|
|
1249
|
+
size: facts.size,
|
|
1250
|
+
uploadedBy: PEOPLE.maya,
|
|
1251
|
+
createdAt: new Date().toISOString(),
|
|
1252
|
+
}
|
|
1253
|
+
attachments.push(row)
|
|
1254
|
+
added.push(row)
|
|
1255
|
+
appendHistory(find(id), 'attachment_added', {
|
|
1256
|
+
data: { attachmentId: row.id, name: row.name, ...(forRepair ? { repairId: forRepair } : {}) },
|
|
1257
|
+
})
|
|
1258
|
+
}
|
|
1259
|
+
return added.map(stamp)
|
|
1260
|
+
},
|
|
1261
|
+
|
|
1262
|
+
remove: async ({ workspaceId, attachmentId: id }: { workspaceId: string; attachmentId: string }) => {
|
|
1263
|
+
remember(workspaceId)
|
|
1264
|
+
const index = attachments.findIndex((row) => row.id === id)
|
|
1265
|
+
if (index < 0) throw new MockApiError('NOT_FOUND', 'Attachment not found')
|
|
1266
|
+
const [row] = attachments.splice(index, 1)
|
|
1267
|
+
appendHistory(find(row!.assetId), 'attachment_removed', {
|
|
1268
|
+
data: { attachmentId: row!.id, name: row!.name },
|
|
1269
|
+
})
|
|
1270
|
+
return { id: row!.id }
|
|
1271
|
+
},
|
|
1272
|
+
},
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* The register in numbers.
|
|
1276
|
+
*
|
|
1277
|
+
* `outForRepair` is a number here rather than null because the shell's mock has every
|
|
1278
|
+
* capability on; the *shape* is what matters — a screen that reads it has to handle both.
|
|
1279
|
+
*/
|
|
1280
|
+
stats: {
|
|
1281
|
+
summary: async ({ workspaceId }: { workspaceId: string }) => {
|
|
1282
|
+
remember(workspaceId)
|
|
1283
|
+
const live = assets.filter((asset) => !asset.archivedAt)
|
|
1284
|
+
const byStatus = {
|
|
1285
|
+
in_stock: 0,
|
|
1286
|
+
assigned: 0,
|
|
1287
|
+
reserved: 0,
|
|
1288
|
+
under_repair: 0,
|
|
1289
|
+
lost: 0,
|
|
1290
|
+
retired: 0,
|
|
1291
|
+
} satisfies Record<AssetStatus, number>
|
|
1292
|
+
for (const asset of live) byStatus[asset.status] += 1
|
|
1293
|
+
const summary: InventoryStats = {
|
|
1294
|
+
total: live.length,
|
|
1295
|
+
archived: assets.length - live.length,
|
|
1296
|
+
byStatus,
|
|
1297
|
+
outForRepair: repairs.filter((repair) => repair.returnedOn === null).length,
|
|
1298
|
+
unassigned: live.filter((asset) => !asset.custodianUserId).length,
|
|
1299
|
+
}
|
|
1300
|
+
return summary
|
|
92
1301
|
},
|
|
93
1302
|
},
|
|
94
1303
|
}
|