@naisys/erp 3.0.0-beta.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.
Files changed (150) hide show
  1. package/bin/naisys-erp +2 -0
  2. package/client-dist/android-chrome-192x192.png +0 -0
  3. package/client-dist/android-chrome-512x512.png +0 -0
  4. package/client-dist/apple-touch-icon.png +0 -0
  5. package/client-dist/assets/index-45dVo30p.css +1 -0
  6. package/client-dist/assets/index-Dffms7F_.js +168 -0
  7. package/client-dist/assets/naisys-logo-CzoPnn5I.webp +0 -0
  8. package/client-dist/favicon.ico +0 -0
  9. package/client-dist/index.html +42 -0
  10. package/client-dist/site.webmanifest +22 -0
  11. package/dist/api-reference.js +101 -0
  12. package/dist/audit.js +14 -0
  13. package/dist/auth-middleware.js +203 -0
  14. package/dist/dbConfig.js +10 -0
  15. package/dist/erpDb.js +34 -0
  16. package/dist/erpServer.js +321 -0
  17. package/dist/error-handler.js +17 -0
  18. package/dist/generated/prisma/client.js +35 -0
  19. package/dist/generated/prisma/commonInputTypes.js +11 -0
  20. package/dist/generated/prisma/enums.js +60 -0
  21. package/dist/generated/prisma/internal/class.js +50 -0
  22. package/dist/generated/prisma/internal/prismaNamespace.js +366 -0
  23. package/dist/generated/prisma/models/Attachment.js +2 -0
  24. package/dist/generated/prisma/models/AuditLog.js +2 -0
  25. package/dist/generated/prisma/models/Field.js +2 -0
  26. package/dist/generated/prisma/models/FieldAttachment.js +2 -0
  27. package/dist/generated/prisma/models/FieldRecord.js +2 -0
  28. package/dist/generated/prisma/models/FieldSet.js +2 -0
  29. package/dist/generated/prisma/models/FieldValue.js +2 -0
  30. package/dist/generated/prisma/models/Item.js +2 -0
  31. package/dist/generated/prisma/models/ItemInstance.js +2 -0
  32. package/dist/generated/prisma/models/LaborTicket.js +2 -0
  33. package/dist/generated/prisma/models/Operation.js +2 -0
  34. package/dist/generated/prisma/models/OperationDependency.js +2 -0
  35. package/dist/generated/prisma/models/OperationFieldRef.js +2 -0
  36. package/dist/generated/prisma/models/OperationRun.js +2 -0
  37. package/dist/generated/prisma/models/OperationRunComment.js +2 -0
  38. package/dist/generated/prisma/models/Order.js +2 -0
  39. package/dist/generated/prisma/models/OrderRevision.js +2 -0
  40. package/dist/generated/prisma/models/OrderRun.js +2 -0
  41. package/dist/generated/prisma/models/SchemaVersion.js +2 -0
  42. package/dist/generated/prisma/models/Session.js +2 -0
  43. package/dist/generated/prisma/models/Step.js +2 -0
  44. package/dist/generated/prisma/models/StepRun.js +2 -0
  45. package/dist/generated/prisma/models/User.js +2 -0
  46. package/dist/generated/prisma/models/UserPermission.js +2 -0
  47. package/dist/generated/prisma/models/WorkCenter.js +2 -0
  48. package/dist/generated/prisma/models/WorkCenterUser.js +2 -0
  49. package/dist/generated/prisma/models.js +2 -0
  50. package/dist/hateoas.js +61 -0
  51. package/dist/route-helpers.js +220 -0
  52. package/dist/routes/admin.js +147 -0
  53. package/dist/routes/audit.js +36 -0
  54. package/dist/routes/auth.js +112 -0
  55. package/dist/routes/dispatch.js +174 -0
  56. package/dist/routes/inventory.js +70 -0
  57. package/dist/routes/item-fields.js +220 -0
  58. package/dist/routes/item-instances.js +426 -0
  59. package/dist/routes/items.js +252 -0
  60. package/dist/routes/labor-tickets.js +268 -0
  61. package/dist/routes/operation-dependencies.js +170 -0
  62. package/dist/routes/operation-field-refs.js +263 -0
  63. package/dist/routes/operation-run-comments.js +108 -0
  64. package/dist/routes/operation-run-transitions.js +249 -0
  65. package/dist/routes/operation-runs.js +299 -0
  66. package/dist/routes/operations.js +283 -0
  67. package/dist/routes/order-revision-transitions.js +86 -0
  68. package/dist/routes/order-revisions.js +327 -0
  69. package/dist/routes/order-run-transitions.js +215 -0
  70. package/dist/routes/order-runs.js +335 -0
  71. package/dist/routes/orders.js +262 -0
  72. package/dist/routes/root.js +123 -0
  73. package/dist/routes/schemas.js +31 -0
  74. package/dist/routes/step-field-attachments.js +231 -0
  75. package/dist/routes/step-fields.js +315 -0
  76. package/dist/routes/step-run-fields.js +438 -0
  77. package/dist/routes/step-run-transitions.js +113 -0
  78. package/dist/routes/step-runs.js +324 -0
  79. package/dist/routes/steps.js +283 -0
  80. package/dist/routes/user-permissions.js +100 -0
  81. package/dist/routes/users.js +381 -0
  82. package/dist/routes/work-centers.js +280 -0
  83. package/dist/schema-registry.js +45 -0
  84. package/dist/services/attachment-service.js +118 -0
  85. package/dist/services/field-ref-service.js +74 -0
  86. package/dist/services/field-service.js +114 -0
  87. package/dist/services/field-value-service.js +256 -0
  88. package/dist/services/item-instance-service.js +155 -0
  89. package/dist/services/item-service.js +56 -0
  90. package/dist/services/labor-ticket-service.js +148 -0
  91. package/dist/services/log-file-service.js +11 -0
  92. package/dist/services/operation-dependency-service.js +30 -0
  93. package/dist/services/operation-run-comment-service.js +26 -0
  94. package/dist/services/operation-run-service.js +347 -0
  95. package/dist/services/operation-service.js +132 -0
  96. package/dist/services/order-revision-service.js +264 -0
  97. package/dist/services/order-run-service.js +356 -0
  98. package/dist/services/order-service.js +68 -0
  99. package/dist/services/revision-diff-service.js +194 -0
  100. package/dist/services/step-run-service.js +106 -0
  101. package/dist/services/step-service.js +89 -0
  102. package/dist/services/user-service.js +132 -0
  103. package/dist/services/work-center-service.js +106 -0
  104. package/dist/supervisorAuth.js +16 -0
  105. package/dist/userService.js +118 -0
  106. package/package.json +75 -0
  107. package/prisma/migrations/20260212170352_init/migration.sql +125 -0
  108. package/prisma/migrations/20260308000000_multi_session/migration.sql +23 -0
  109. package/prisma/migrations/20260309000000_add_user_api_key/migration.sql +5 -0
  110. package/prisma/migrations/20260309010000_add_plan_operations/migration.sql +21 -0
  111. package/prisma/migrations/20260309020000_rename_exec_orders_to_order_runs/migration.sql +13 -0
  112. package/prisma/migrations/20260310000000_rename_plan_order_revs_to_order_revisions/migration.sql +22 -0
  113. package/prisma/migrations/20260310100000_rename_plan_orders_to_orders/migration.sql +23 -0
  114. package/prisma/migrations/20260310200000_rename_order_no_to_run_no/migration.sql +3 -0
  115. package/prisma/migrations/20260312000000_add_user_permissions/migration.sql +16 -0
  116. package/prisma/migrations/20260313000000_rename_plan_operations_to_operations/migration.sql +2 -0
  117. package/prisma/migrations/20260313100000_add_steps/migration.sql +20 -0
  118. package/prisma/migrations/20260314000000_add_step_fields/migration.sql +22 -0
  119. package/prisma/migrations/20260315000000_add_operation_runs/migration.sql +24 -0
  120. package/prisma/migrations/20260315100000_add_step_runs/migration.sql +40 -0
  121. package/prisma/migrations/20260316000000_drop_order_name/migration.sql +12 -0
  122. package/prisma/migrations/20260317000000_add_attachments/migration.sql +28 -0
  123. package/prisma/migrations/20260317000000_add_items/migration.sql +21 -0
  124. package/prisma/migrations/20260317100000_add_order_item_id/migration.sql +8 -0
  125. package/prisma/migrations/20260318000000_add_labor_tickets/migration.sql +27 -0
  126. package/prisma/migrations/20260319000000_add_operation_dependencies/migration.sql +17 -0
  127. package/prisma/migrations/20260320000000_step_field_is_array/migration.sql +5 -0
  128. package/prisma/migrations/20260320100000_rename_is_array_to_multi_value/migration.sql +2 -0
  129. package/prisma/migrations/20260320200000_add_field_types/migration.sql +2 -0
  130. package/prisma/migrations/20260321000000_add_multi_set/migration.sql +13 -0
  131. package/prisma/migrations/20260322000000_add_field_sets/migration.sql +90 -0
  132. package/prisma/migrations/20260323000000_add_item_instances/migration.sql +18 -0
  133. package/prisma/migrations/20260324000000_add_field_records/migration.sql +77 -0
  134. package/prisma/migrations/20260325000000_add_run_costs/migration.sql +3 -0
  135. package/prisma/migrations/20260326000000_add_comments/migration.sql +16 -0
  136. package/prisma/migrations/20260327000000_move_assigned_to_op_run/migration.sql +3 -0
  137. package/prisma/migrations/20260328000000_add_step_completion_note/migration.sql +2 -0
  138. package/prisma/migrations/20260328000000_add_step_title/migration.sql +2 -0
  139. package/prisma/migrations/20260329000000_rename_notes_to_release_note/migration.sql +2 -0
  140. package/prisma/migrations/20260329000000_simplify_order_run_dates/migration.sql +5 -0
  141. package/prisma/migrations/20260330000000_add_operation_run_completion_note/migration.sql +2 -0
  142. package/prisma/migrations/20260331000000_add_work_centers/migration.sql +30 -0
  143. package/prisma/migrations/20260401000000_fix_field_values_column_shift/migration.sql +26 -0
  144. package/prisma/migrations/20260402000000_rename_completion_note_to_status_note/migration.sql +5 -0
  145. package/prisma/migrations/20260403000000_add_operation_field_refs/migration.sql +16 -0
  146. package/prisma/migrations/20260404000000_rename_multi_value_to_is_array/migration.sql +2 -0
  147. package/prisma/migrations/20260404100000_add_attachment_public_id/migration.sql +8 -0
  148. package/prisma/migrations/migration_lock.toml +3 -0
  149. package/prisma/schema.prisma +595 -0
  150. package/prisma.config.ts +18 -0
@@ -0,0 +1,426 @@
1
+ import { CreateItemInstanceSchema, DeleteSetMutateResponseSchema, ErrorResponseSchema, fieldTypeString, FieldValueMutateResponseSchema, getValueFormatHint, ItemInstanceListQuerySchema, ItemInstanceListResponseSchema, ItemInstanceSchema, KeyCreateResponseSchema, MutateResponseSchema, UpdateFieldValueSchema, UpdateItemInstanceSchema, } from "@naisys/erp-shared";
2
+ import { z } from "zod/v4";
3
+ import { hasPermission, requirePermission } from "../auth-middleware.js";
4
+ import { notFound, unprocessable } from "../error-handler.js";
5
+ import { API_PREFIX, paginationLinks, schemaLink, selfLink, } from "../hateoas.js";
6
+ import { formatAuditFields, mutationResult, useFullSerializer, wantsFullResponse, } from "../route-helpers.js";
7
+ import { checkFieldValueShape, deleteFieldValueSet, deserializeFieldValue, serializeFieldValue, upsertFieldValue, validateFieldValue, } from "../services/field-value-service.js";
8
+ import { createItemInstance, deleteItemInstance, ensureItemInstanceFieldRecord, findItemInstance, findItemInstanceWithField, listItemInstances, updateItemInstance, } from "../services/item-instance-service.js";
9
+ import { findExisting as findItem } from "../services/item-service.js";
10
+ const ParamsSchema = z.object({
11
+ key: z.string(),
12
+ });
13
+ const InstanceParamsSchema = z.object({
14
+ key: z.string(),
15
+ instanceId: z.coerce.number(),
16
+ });
17
+ const FieldSeqNoParamsSchema = z.object({
18
+ key: z.string(),
19
+ instanceId: z.coerce.number(),
20
+ fieldSeqNo: z.coerce.number().int(),
21
+ });
22
+ const SetIndexParamsSchema = z.object({
23
+ key: z.string(),
24
+ instanceId: z.coerce.number(),
25
+ setIndex: z.coerce.number().int(),
26
+ });
27
+ const SetFieldSeqNoParamsSchema = z.object({
28
+ key: z.string(),
29
+ instanceId: z.coerce.number(),
30
+ setIndex: z.coerce.number().int().min(0),
31
+ fieldSeqNo: z.coerce.number().int(),
32
+ });
33
+ function instanceBasePath(itemKey) {
34
+ return `items/${itemKey}/instances`;
35
+ }
36
+ function instanceLinks(itemKey, instanceId, inst) {
37
+ const base = instanceBasePath(itemKey);
38
+ const links = [
39
+ selfLink(`/${base}/${instanceId}`),
40
+ {
41
+ rel: "collection",
42
+ href: `${API_PREFIX}/${base}`,
43
+ title: "Instances",
44
+ },
45
+ {
46
+ rel: "parent",
47
+ href: `${API_PREFIX}/items/${itemKey}`,
48
+ title: "Item",
49
+ },
50
+ schemaLink("ItemInstance"),
51
+ ];
52
+ if (inst.orderRun) {
53
+ links.push({
54
+ rel: "orderRun",
55
+ href: `${API_PREFIX}/orders/${inst.orderRun.order.key}/runs/${inst.orderRun.runNo}`,
56
+ title: "Order Run",
57
+ });
58
+ }
59
+ return links;
60
+ }
61
+ function instanceActions(itemKey, instanceId, user) {
62
+ if (!hasPermission(user, "item_manager"))
63
+ return [];
64
+ const href = `${API_PREFIX}/${instanceBasePath(itemKey)}/${instanceId}`;
65
+ return [
66
+ {
67
+ rel: "update",
68
+ href,
69
+ method: "PUT",
70
+ title: "Update",
71
+ schema: `${API_PREFIX}/schemas/UpdateItemInstance`,
72
+ body: { key: "" },
73
+ },
74
+ {
75
+ rel: "delete",
76
+ href,
77
+ method: "DELETE",
78
+ title: "Delete",
79
+ },
80
+ ];
81
+ }
82
+ function orderKey(inst) {
83
+ return inst.orderRun?.order.key ?? null;
84
+ }
85
+ function orderRunNo(inst) {
86
+ return inst.orderRun?.runNo ?? null;
87
+ }
88
+ function buildFieldValues(inst) {
89
+ const fields = inst.item.fieldSet?.fields ?? [];
90
+ if (fields.length === 0)
91
+ return [];
92
+ const storedFieldValues = inst.fieldRecord?.fieldValues ?? [];
93
+ const maxSetIndex = storedFieldValues.reduce((max, fv) => Math.max(max, fv.setIndex), -1);
94
+ const setCount = Math.max(1, maxSetIndex + 1);
95
+ const fieldValues = [];
96
+ for (let si = 0; si < setCount; si++) {
97
+ for (const field of fields) {
98
+ const stored = storedFieldValues.find((fv) => fv.fieldId === field.id && fv.setIndex === si);
99
+ const value = deserializeFieldValue(stored?.value ?? "", field.isArray);
100
+ const attachments = field.type === "attachment" && stored
101
+ ? stored.fieldAttachments.map((sfa) => ({
102
+ id: sfa.attachment.publicId,
103
+ filename: sfa.attachment.filename,
104
+ fileSize: sfa.attachment.fileSize,
105
+ }))
106
+ : undefined;
107
+ const fieldType = fieldTypeString(field.type, field.isArray);
108
+ fieldValues.push({
109
+ fieldId: field.id,
110
+ fieldSeqNo: field.seqNo,
111
+ label: field.label,
112
+ type: fieldType,
113
+ valueFormat: getValueFormatHint(fieldType),
114
+ required: field.required,
115
+ setIndex: si,
116
+ value,
117
+ attachments,
118
+ validation: validateFieldValue(field.type, field.isArray, field.required, value),
119
+ });
120
+ }
121
+ }
122
+ return fieldValues;
123
+ }
124
+ function buildActionTemplates(itemKey, instanceId, user, hasFields) {
125
+ if (!hasPermission(user, "item_manager") || !hasFields)
126
+ return [];
127
+ const instanceHref = `${API_PREFIX}/${instanceBasePath(itemKey)}/${instanceId}`;
128
+ return [
129
+ {
130
+ rel: "updateField",
131
+ hrefTemplate: `${instanceHref}/fields/{fieldSeqNo}`,
132
+ method: "PUT",
133
+ title: "Update Field Value",
134
+ schema: `${API_PREFIX}/schemas/UpdateFieldValue`,
135
+ body: { value: "" },
136
+ },
137
+ ];
138
+ }
139
+ function formatInstance(inst, user) {
140
+ const hasFields = (inst.item.fieldSet?.fields ?? []).length > 0;
141
+ return {
142
+ id: inst.id,
143
+ itemKey: inst.item.key,
144
+ orderKey: orderKey(inst),
145
+ orderRunNo: orderRunNo(inst),
146
+ key: inst.key,
147
+ quantity: inst.quantity,
148
+ fieldValues: buildFieldValues(inst),
149
+ ...formatAuditFields(inst),
150
+ _links: instanceLinks(inst.item.key, inst.id, inst),
151
+ _actions: instanceActions(inst.item.key, inst.id, user),
152
+ _actionTemplates: buildActionTemplates(inst.item.key, inst.id, user, hasFields),
153
+ };
154
+ }
155
+ function formatListInstance(inst, _user) {
156
+ return {
157
+ id: inst.id,
158
+ itemKey: inst.item.key,
159
+ orderKey: orderKey(inst),
160
+ orderRunNo: orderRunNo(inst),
161
+ key: inst.key,
162
+ quantity: inst.quantity,
163
+ fieldValues: buildFieldValues(inst),
164
+ ...formatAuditFields(inst),
165
+ };
166
+ }
167
+ export default function itemInstanceRoutes(fastify) {
168
+ const app = fastify.withTypeProvider();
169
+ // LIST
170
+ app.get("/", {
171
+ schema: {
172
+ description: "List item instances with pagination and search",
173
+ tags: ["Item Instances"],
174
+ params: ParamsSchema,
175
+ querystring: ItemInstanceListQuerySchema,
176
+ response: {
177
+ 200: ItemInstanceListResponseSchema,
178
+ 404: ErrorResponseSchema,
179
+ },
180
+ },
181
+ handler: async (request, reply) => {
182
+ const { key } = request.params;
183
+ const { page, pageSize, search } = request.query;
184
+ const item = await findItem(key);
185
+ if (!item)
186
+ return notFound(reply, `Item '${key}' not found`);
187
+ const where = { itemId: item.id };
188
+ if (search) {
189
+ where.key = { contains: search };
190
+ }
191
+ const [instances, total] = await listItemInstances(where, page, pageSize);
192
+ const base = instanceBasePath(key);
193
+ return {
194
+ items: instances.map((inst) => formatListInstance(inst, request.erpUser)),
195
+ total,
196
+ page,
197
+ pageSize,
198
+ _links: paginationLinks(base, page, pageSize, total, { search }),
199
+ _linkTemplates: [
200
+ {
201
+ rel: "item",
202
+ hrefTemplate: `${API_PREFIX}/items/${key}/instances/{id}`,
203
+ },
204
+ ],
205
+ _actions: hasPermission(request.erpUser, "item_manager")
206
+ ? [
207
+ {
208
+ rel: "create",
209
+ href: `${API_PREFIX}/${base}`,
210
+ method: "POST",
211
+ title: "Create Instance",
212
+ schema: `${API_PREFIX}/schemas/CreateItemInstance`,
213
+ body: { key: "" },
214
+ },
215
+ ]
216
+ : [],
217
+ };
218
+ },
219
+ });
220
+ // CREATE
221
+ app.post("/", {
222
+ schema: {
223
+ description: "Create a new item instance",
224
+ tags: ["Item Instances"],
225
+ params: ParamsSchema,
226
+ body: CreateItemInstanceSchema,
227
+ response: {
228
+ 201: KeyCreateResponseSchema,
229
+ 404: ErrorResponseSchema,
230
+ },
231
+ },
232
+ preHandler: requirePermission("item_manager"),
233
+ handler: async (request, reply) => {
234
+ const { key: itemKey } = request.params;
235
+ const { key, quantity, orderRunId } = request.body;
236
+ const userId = request.erpUser.id;
237
+ const item = await findItem(itemKey);
238
+ if (!item)
239
+ return notFound(reply, `Item '${itemKey}' not found`);
240
+ const inst = await createItemInstance(item.id, key, quantity, orderRunId, userId);
241
+ const full = formatInstance(inst, request.erpUser);
242
+ reply.status(201);
243
+ return mutationResult(request, reply, full, {
244
+ id: full.id,
245
+ key: full.key,
246
+ _links: full._links,
247
+ _actions: full._actions,
248
+ });
249
+ },
250
+ });
251
+ // GET by id
252
+ app.get("/:instanceId", {
253
+ schema: {
254
+ description: "Get a single item instance",
255
+ tags: ["Item Instances"],
256
+ params: InstanceParamsSchema,
257
+ response: {
258
+ 200: ItemInstanceSchema,
259
+ 404: ErrorResponseSchema,
260
+ },
261
+ },
262
+ handler: async (request, reply) => {
263
+ const { instanceId } = request.params;
264
+ const inst = await findItemInstance(instanceId);
265
+ if (!inst)
266
+ return notFound(reply, `Item instance ${instanceId} not found`);
267
+ return formatInstance(inst, request.erpUser);
268
+ },
269
+ });
270
+ // UPDATE
271
+ app.put("/:instanceId", {
272
+ schema: {
273
+ description: "Update an item instance",
274
+ tags: ["Item Instances"],
275
+ params: InstanceParamsSchema,
276
+ body: UpdateItemInstanceSchema,
277
+ response: {
278
+ 200: MutateResponseSchema,
279
+ 404: ErrorResponseSchema,
280
+ },
281
+ },
282
+ preHandler: requirePermission("item_manager"),
283
+ handler: async (request, reply) => {
284
+ const { instanceId } = request.params;
285
+ const data = request.body;
286
+ const userId = request.erpUser.id;
287
+ const existing = await findItemInstance(instanceId);
288
+ if (!existing)
289
+ return notFound(reply, `Item instance ${instanceId} not found`);
290
+ const inst = await updateItemInstance(instanceId, data, userId);
291
+ const full = formatInstance(inst, request.erpUser);
292
+ return mutationResult(request, reply, full, {
293
+ _actions: full._actions,
294
+ });
295
+ },
296
+ });
297
+ // DELETE
298
+ app.delete("/:instanceId", {
299
+ schema: {
300
+ description: "Delete an item instance",
301
+ tags: ["Item Instances"],
302
+ params: InstanceParamsSchema,
303
+ response: {
304
+ 204: z.void(),
305
+ 404: ErrorResponseSchema,
306
+ },
307
+ },
308
+ preHandler: requirePermission("item_manager"),
309
+ handler: async (request, reply) => {
310
+ const { instanceId } = request.params;
311
+ const existing = await findItemInstance(instanceId);
312
+ if (!existing)
313
+ return notFound(reply, `Item instance ${instanceId} not found`);
314
+ await deleteItemInstance(instanceId);
315
+ reply.status(204);
316
+ },
317
+ });
318
+ // Shared handler for updating a single field value
319
+ async function handleFieldUpdate(request, reply, setIndex) {
320
+ const { instanceId, fieldSeqNo } = request.params;
321
+ const { value } = request.body;
322
+ const userId = request.erpUser.id;
323
+ const inst = await findItemInstanceWithField(instanceId, fieldSeqNo);
324
+ if (!inst)
325
+ return notFound(reply, `Item instance ${instanceId} not found`);
326
+ const field = inst.item.fieldSet?.fields[0];
327
+ if (!field)
328
+ return notFound(reply, `Field not found`);
329
+ const shapeErr = checkFieldValueShape(field.label, field.type, field.isArray, value);
330
+ if (shapeErr)
331
+ return unprocessable(reply, shapeErr);
332
+ const fieldRecordId = await ensureItemInstanceFieldRecord(instanceId, userId);
333
+ if (!fieldRecordId)
334
+ return notFound(reply, "Item has no field set");
335
+ await upsertFieldValue(fieldRecordId, field.id, setIndex, value, userId);
336
+ // Return deserialized value
337
+ const responseValue = deserializeFieldValue(serializeFieldValue(value), field.isArray);
338
+ const validation = validateFieldValue(field.type, field.isArray, field.required, responseValue);
339
+ const fieldType = fieldTypeString(field.type, field.isArray);
340
+ const full = {
341
+ fieldId: field.id,
342
+ fieldSeqNo: field.seqNo,
343
+ label: field.label,
344
+ type: fieldType,
345
+ valueFormat: getValueFormatHint(fieldType),
346
+ required: field.required,
347
+ setIndex,
348
+ value: responseValue,
349
+ validation,
350
+ };
351
+ return mutationResult(request, reply, full, {
352
+ value: responseValue,
353
+ validation,
354
+ });
355
+ }
356
+ // UPDATE single field value (implicit set 0)
357
+ app.put("/:instanceId/fields/:fieldSeqNo", {
358
+ schema: {
359
+ description: "Update a single field value on an item instance (implicit set 0). " +
360
+ "For multi-set items, use /sets/{setIndex}/fields/{fieldSeqNo} instead.",
361
+ tags: ["Item Instances"],
362
+ params: FieldSeqNoParamsSchema,
363
+ body: UpdateFieldValueSchema,
364
+ response: {
365
+ 200: FieldValueMutateResponseSchema,
366
+ 404: ErrorResponseSchema,
367
+ },
368
+ },
369
+ preHandler: requirePermission("item_manager"),
370
+ handler: async (request, reply) => handleFieldUpdate(request, reply, 0),
371
+ });
372
+ // UPDATE single field value (explicit set index)
373
+ app.put("/:instanceId/sets/:setIndex/fields/:fieldSeqNo", {
374
+ schema: {
375
+ description: "Update a single field value on a specific set of an item instance",
376
+ tags: ["Item Instances"],
377
+ params: SetFieldSeqNoParamsSchema,
378
+ body: UpdateFieldValueSchema,
379
+ response: {
380
+ 200: FieldValueMutateResponseSchema,
381
+ 404: ErrorResponseSchema,
382
+ },
383
+ },
384
+ preHandler: requirePermission("item_manager"),
385
+ handler: async (request, reply) => handleFieldUpdate(request, reply, request.params.setIndex),
386
+ });
387
+ // DELETE a field value set
388
+ app.delete("/:instanceId/sets/:setIndex", {
389
+ schema: {
390
+ description: "Delete all field values for a set and re-index remaining sets",
391
+ tags: ["Item Instances"],
392
+ params: SetIndexParamsSchema,
393
+ response: {
394
+ 200: DeleteSetMutateResponseSchema,
395
+ 404: ErrorResponseSchema,
396
+ },
397
+ },
398
+ preHandler: requirePermission("item_manager"),
399
+ handler: async (request, reply) => {
400
+ const { instanceId, setIndex } = request.params;
401
+ const existing = await findItemInstance(instanceId);
402
+ if (!existing)
403
+ return notFound(reply, `Item instance ${instanceId} not found`);
404
+ if (!existing.fieldRecord) {
405
+ return notFound(reply, "No field values to delete");
406
+ }
407
+ await deleteFieldValueSet(existing.fieldRecord.id, setIndex);
408
+ const inst = await findItemInstance(instanceId);
409
+ if (!inst)
410
+ return notFound(reply, `Item instance ${instanceId} not found`);
411
+ if (wantsFullResponse(request)) {
412
+ useFullSerializer(reply);
413
+ return formatInstance(inst, request.erpUser);
414
+ }
415
+ // Compute set count from remaining field values
416
+ const storedFieldValues = inst.fieldRecord?.fieldValues ?? [];
417
+ const maxSetIndex = storedFieldValues.reduce((max, fv) => Math.max(max, fv.setIndex), -1);
418
+ const setCount = Math.max(1, maxSetIndex + 1);
419
+ return {
420
+ setCount,
421
+ _actions: instanceActions(inst.item.key, inst.id, request.erpUser),
422
+ };
423
+ },
424
+ });
425
+ }
426
+ //# sourceMappingURL=item-instances.js.map
@@ -0,0 +1,252 @@
1
+ import { CreateItemSchema, ErrorResponseSchema, ItemListQuerySchema, ItemListResponseSchema, ItemSchema, KeyCreateResponseSchema, MutateResponseSchema, UpdateItemSchema, } from "@naisys/erp-shared";
2
+ import { z } from "zod/v4";
3
+ import { hasPermission, requirePermission } from "../auth-middleware.js";
4
+ import { notFound } from "../error-handler.js";
5
+ import { API_PREFIX, collectionLink, paginationLinks, schemaLink, selfLink, } from "../hateoas.js";
6
+ import { calcNextSeqNo, childItemLinks, formatAuditFields, mutationResult, } from "../route-helpers.js";
7
+ import { createItem, deleteItem, findExisting, listItems, updateItem, } from "../services/item-service.js";
8
+ const RESOURCE = "items";
9
+ const KeyParamsSchema = z.object({
10
+ key: z.string(),
11
+ });
12
+ function itemLinks(key) {
13
+ return [
14
+ selfLink(`/${RESOURCE}/${key}`),
15
+ collectionLink(RESOURCE),
16
+ schemaLink("Item"),
17
+ ];
18
+ }
19
+ function itemActions(key, user) {
20
+ if (!hasPermission(user, "item_manager"))
21
+ return [];
22
+ const href = `${API_PREFIX}/${RESOURCE}/${key}`;
23
+ return [
24
+ {
25
+ rel: "update",
26
+ href,
27
+ method: "PUT",
28
+ title: "Update",
29
+ schema: `${API_PREFIX}/schemas/UpdateItem`,
30
+ },
31
+ {
32
+ rel: "delete",
33
+ href,
34
+ method: "DELETE",
35
+ title: "Delete",
36
+ },
37
+ ];
38
+ }
39
+ function formatItemFieldListResponse(itemKey, user, fields) {
40
+ const maxSeq = fields.length > 0 ? fields[fields.length - 1].seqNo : 0;
41
+ const base = `/items/${itemKey}/fields`;
42
+ return {
43
+ items: fields.map((field) => formatItemField(itemKey, user, field)),
44
+ total: fields.length,
45
+ nextSeqNo: calcNextSeqNo(maxSeq),
46
+ _links: [selfLink(base)],
47
+ _actions: hasPermission(user, "item_manager")
48
+ ? [
49
+ {
50
+ rel: "create",
51
+ href: `${API_PREFIX}${base}`,
52
+ method: "POST",
53
+ title: "Add Field",
54
+ schema: `${API_PREFIX}/schemas/CreateField`,
55
+ },
56
+ ]
57
+ : [],
58
+ };
59
+ }
60
+ function formatItemField(itemKey, user, field) {
61
+ const base = `/items/${itemKey}/fields`;
62
+ return {
63
+ id: field.id,
64
+ fieldSetId: field.fieldSetId,
65
+ seqNo: field.seqNo,
66
+ label: field.label,
67
+ type: field.type,
68
+ isArray: field.isArray,
69
+ required: field.required,
70
+ ...formatAuditFields(field),
71
+ _links: childItemLinks(base, field.seqNo, "Fields", `/items/${itemKey}`, "Item", "Field"),
72
+ _actions: hasPermission(user, "item_manager")
73
+ ? [
74
+ {
75
+ rel: "update",
76
+ href: `${API_PREFIX}${base}/${field.seqNo}`,
77
+ method: "PUT",
78
+ title: "Update",
79
+ schema: `${API_PREFIX}/schemas/UpdateField`,
80
+ },
81
+ {
82
+ rel: "delete",
83
+ href: `${API_PREFIX}${base}/${field.seqNo}`,
84
+ method: "DELETE",
85
+ title: "Delete",
86
+ },
87
+ ]
88
+ : [],
89
+ };
90
+ }
91
+ function formatItem(item, user) {
92
+ return {
93
+ id: item.id,
94
+ key: item.key,
95
+ description: item.description,
96
+ fields: formatItemFieldListResponse(item.key, user, item.fieldSet?.fields ?? []),
97
+ ...formatAuditFields(item),
98
+ _links: itemLinks(item.key),
99
+ _actions: itemActions(item.key, user),
100
+ };
101
+ }
102
+ function formatListItem(item, user) {
103
+ return {
104
+ id: item.id,
105
+ key: item.key,
106
+ description: item.description,
107
+ fields: formatItemFieldListResponse(item.key, user, item.fieldSet?.fields ?? []),
108
+ ...formatAuditFields(item),
109
+ };
110
+ }
111
+ export default function itemRoutes(fastify) {
112
+ const app = fastify.withTypeProvider();
113
+ // LIST
114
+ app.get("/", {
115
+ schema: {
116
+ description: "List items with pagination and search",
117
+ tags: ["Items"],
118
+ querystring: ItemListQuerySchema,
119
+ response: {
120
+ 200: ItemListResponseSchema,
121
+ },
122
+ },
123
+ handler: async (request) => {
124
+ const { page, pageSize, search } = request.query;
125
+ const where = {};
126
+ if (search) {
127
+ where.OR = [
128
+ { key: { contains: search } },
129
+ { description: { contains: search } },
130
+ ];
131
+ }
132
+ const [items, total] = await listItems(where, page, pageSize);
133
+ return {
134
+ items: items.map((item) => formatListItem(item, request.erpUser)),
135
+ total,
136
+ page,
137
+ pageSize,
138
+ _links: paginationLinks(RESOURCE, page, pageSize, total, { search }),
139
+ _linkTemplates: [
140
+ { rel: "item", hrefTemplate: `${API_PREFIX}/items/{key}` },
141
+ ],
142
+ _actions: hasPermission(request.erpUser, "item_manager")
143
+ ? [
144
+ {
145
+ rel: "create",
146
+ href: `${API_PREFIX}/${RESOURCE}`,
147
+ method: "POST",
148
+ title: "Create Item",
149
+ schema: `${API_PREFIX}/schemas/CreateItem`,
150
+ },
151
+ ]
152
+ : [],
153
+ };
154
+ },
155
+ });
156
+ // CREATE
157
+ app.post("/", {
158
+ schema: {
159
+ description: "Create a new item",
160
+ tags: ["Items"],
161
+ body: CreateItemSchema,
162
+ response: {
163
+ 201: KeyCreateResponseSchema,
164
+ },
165
+ },
166
+ preHandler: requirePermission("item_manager"),
167
+ handler: async (request, reply) => {
168
+ const { key, description } = request.body;
169
+ const userId = request.erpUser.id;
170
+ const item = await createItem(key, description, userId);
171
+ const full = formatItem(item, request.erpUser);
172
+ reply.status(201);
173
+ return mutationResult(request, reply, full, {
174
+ id: full.id,
175
+ key: full.key,
176
+ _links: full._links,
177
+ _actions: full._actions,
178
+ });
179
+ },
180
+ });
181
+ // GET by key
182
+ app.get("/:key", {
183
+ schema: {
184
+ description: "Get a single item by key",
185
+ tags: ["Items"],
186
+ params: KeyParamsSchema,
187
+ response: {
188
+ 200: ItemSchema,
189
+ 404: ErrorResponseSchema,
190
+ },
191
+ },
192
+ handler: async (request, reply) => {
193
+ const { key } = request.params;
194
+ const item = await findExisting(key);
195
+ if (!item) {
196
+ return notFound(reply, `Item '${key}' not found`);
197
+ }
198
+ return formatItem(item, request.erpUser);
199
+ },
200
+ });
201
+ // UPDATE
202
+ app.put("/:key", {
203
+ schema: {
204
+ description: "Update an item",
205
+ tags: ["Items"],
206
+ params: KeyParamsSchema,
207
+ body: UpdateItemSchema,
208
+ response: {
209
+ 200: MutateResponseSchema,
210
+ 404: ErrorResponseSchema,
211
+ },
212
+ },
213
+ preHandler: requirePermission("item_manager"),
214
+ handler: async (request, reply) => {
215
+ const { key } = request.params;
216
+ const data = request.body;
217
+ const userId = request.erpUser.id;
218
+ const existing = await findExisting(key);
219
+ if (!existing) {
220
+ return notFound(reply, `Item '${key}' not found`);
221
+ }
222
+ const item = await updateItem(key, data, userId);
223
+ const full = formatItem(item, request.erpUser);
224
+ return mutationResult(request, reply, full, {
225
+ _actions: full._actions,
226
+ });
227
+ },
228
+ });
229
+ // DELETE
230
+ app.delete("/:key", {
231
+ schema: {
232
+ description: "Delete an item",
233
+ tags: ["Items"],
234
+ params: KeyParamsSchema,
235
+ response: {
236
+ 204: z.void(),
237
+ 404: ErrorResponseSchema,
238
+ },
239
+ },
240
+ preHandler: requirePermission("item_manager"),
241
+ handler: async (request, reply) => {
242
+ const { key } = request.params;
243
+ const existing = await findExisting(key);
244
+ if (!existing) {
245
+ return notFound(reply, `Item '${key}' not found`);
246
+ }
247
+ await deleteItem(key);
248
+ reply.status(204);
249
+ },
250
+ });
251
+ }
252
+ //# sourceMappingURL=items.js.map