@naisys/erp 3.0.0-beta.10

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-C9uuPHLH.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,299 @@
1
+ import { ErrorResponseSchema, MutateResponseSchema, OperationRunListResponseSchema, OperationRunSchema, OperationRunStatus, UpdateOperationRunSchema, } from "@naisys/erp-shared";
2
+ import { z } from "zod/v4";
3
+ import { hasPermission, requirePermission } from "../auth-middleware.js";
4
+ import { conflict, notFound } from "../error-handler.js";
5
+ import { API_PREFIX, selfLink } from "../hateoas.js";
6
+ import { checkOrderRunStarted, checkWorkCenterAccess, childItemLinks, formatAuditFields, formatDate, resolveActions, resolveOpRun, resolveOrderRun, useFullSerializer, wantsFullResponse, } from "../route-helpers.js";
7
+ import { checkStepsComplete, getOpRun, getOpRunFieldRefSummary, getOpRunStepSummary, listOpRuns, updateOpRun, validateStatusFor, } from "../services/operation-run-service.js";
8
+ function opRunResource(orderKey, runNo) {
9
+ return `orders/${orderKey}/runs/${runNo}/ops`;
10
+ }
11
+ async function opRunItemActions(orderKey, runNo, seqNo, opRunId, operationId, status, user) {
12
+ const href = `${API_PREFIX}/${opRunResource(orderKey, runNo)}/${seqNo}`;
13
+ const isExecutor = hasPermission(user, "order_executor");
14
+ const stepsErr = isExecutor && status === OperationRunStatus.in_progress
15
+ ? await checkStepsComplete(opRunId)
16
+ : null;
17
+ const wcErr = user ? await checkWorkCenterAccess(operationId, user) : null;
18
+ return resolveActions([
19
+ {
20
+ rel: "assign",
21
+ method: "PUT",
22
+ title: "Assign",
23
+ schema: `${API_PREFIX}/schemas/UpdateOperationRun`,
24
+ body: { assignedToId: 0 },
25
+ permission: "order_manager",
26
+ statuses: [
27
+ OperationRunStatus.blocked,
28
+ OperationRunStatus.pending,
29
+ OperationRunStatus.in_progress,
30
+ ],
31
+ },
32
+ {
33
+ rel: "add-comment",
34
+ path: "/comments",
35
+ method: "POST",
36
+ title: "Add Comment",
37
+ schema: `${API_PREFIX}/schemas/CreateOperationRunComment`,
38
+ body: { body: "" },
39
+ permission: "order_executor",
40
+ },
41
+ {
42
+ rel: "start",
43
+ path: "/start",
44
+ method: "POST",
45
+ title: "Start",
46
+ permission: "order_executor",
47
+ statuses: [OperationRunStatus.blocked, OperationRunStatus.pending],
48
+ disabledWhen: (ctx) => wcErr ??
49
+ (ctx.status === OperationRunStatus.blocked
50
+ ? "Operation is blocked by incomplete predecessors"
51
+ : null),
52
+ },
53
+ {
54
+ rel: "update",
55
+ method: "PUT",
56
+ title: "Update",
57
+ schema: `${API_PREFIX}/schemas/UpdateOperationRun`,
58
+ body: { assignedToId: 0 },
59
+ permission: "order_executor",
60
+ statuses: [OperationRunStatus.pending, OperationRunStatus.in_progress],
61
+ },
62
+ {
63
+ rel: "complete",
64
+ path: "/complete",
65
+ method: "POST",
66
+ title: "Complete",
67
+ schema: `${API_PREFIX}/schemas/CompleteOperationRun`,
68
+ body: { note: "" },
69
+ permission: "order_executor",
70
+ statuses: [OperationRunStatus.in_progress],
71
+ disabledWhen: () => wcErr ?? stepsErr,
72
+ },
73
+ {
74
+ rel: "skip",
75
+ path: "/skip",
76
+ method: "POST",
77
+ title: "Skip",
78
+ permission: "order_manager",
79
+ statuses: [OperationRunStatus.blocked, OperationRunStatus.pending],
80
+ disabledWhen: () => wcErr,
81
+ },
82
+ {
83
+ rel: "fail",
84
+ path: "/fail",
85
+ method: "POST",
86
+ title: "Fail",
87
+ permission: "order_manager",
88
+ statuses: [OperationRunStatus.in_progress],
89
+ disabledWhen: () => wcErr,
90
+ },
91
+ {
92
+ rel: "reopen",
93
+ path: "/reopen",
94
+ method: "POST",
95
+ title: "Reopen",
96
+ permission: "order_manager",
97
+ statuses: [
98
+ OperationRunStatus.completed,
99
+ OperationRunStatus.skipped,
100
+ OperationRunStatus.failed,
101
+ ],
102
+ disabledWhen: () => wcErr,
103
+ },
104
+ ], href, { status, user });
105
+ }
106
+ const RunNoParamsSchema = z.object({
107
+ orderKey: z.string(),
108
+ runNo: z.coerce.number().int(),
109
+ });
110
+ export const SeqNoParamsSchema = z.object({
111
+ orderKey: z.string(),
112
+ runNo: z.coerce.number().int(),
113
+ seqNo: z.coerce.number().int(),
114
+ });
115
+ export async function formatOpRun(orderKey, runNo, user, opRun) {
116
+ const seqNo = opRun.operation.seqNo;
117
+ const [stepSummaryRows, fieldRefSummary] = await Promise.all([
118
+ getOpRunStepSummary(opRun.id),
119
+ getOpRunFieldRefSummary(opRun.operationId, opRun.orderRunId, orderKey, runNo),
120
+ ]);
121
+ return {
122
+ id: opRun.id,
123
+ orderRunId: opRun.orderRunId,
124
+ operationId: opRun.operationId,
125
+ seqNo,
126
+ title: opRun.operation.title,
127
+ description: opRun.operation.description,
128
+ workCenterKey: opRun.operation.workCenter?.key ?? null,
129
+ status: opRun.status,
130
+ assignedTo: opRun.assignedTo?.username ?? null,
131
+ cost: opRun.cost,
132
+ note: opRun.statusNote ?? null,
133
+ completedAt: formatDate(opRun.completedAt),
134
+ stepSummary: stepSummaryRows.map((sr) => ({
135
+ seqNo: sr.step.seqNo,
136
+ title: sr.step.title,
137
+ completed: sr.completed,
138
+ })),
139
+ ...(fieldRefSummary.length > 0
140
+ ? {
141
+ fieldRefSummary,
142
+ }
143
+ : {}),
144
+ ...formatAuditFields(opRun),
145
+ _links: [
146
+ ...childItemLinks("/" + opRunResource(orderKey, runNo), seqNo, "Operation Runs", "/orders/" + orderKey + "/runs/" + runNo, "Order Run", "OperationRun", "run"),
147
+ {
148
+ rel: "steps",
149
+ href: `${API_PREFIX}/${opRunResource(orderKey, runNo)}/${seqNo}/steps`,
150
+ title: "Step Runs",
151
+ },
152
+ {
153
+ rel: "labor",
154
+ href: `${API_PREFIX}/${opRunResource(orderKey, runNo)}/${seqNo}/labor`,
155
+ title: "Labor Tickets",
156
+ },
157
+ {
158
+ rel: "comments",
159
+ href: `${API_PREFIX}/${opRunResource(orderKey, runNo)}/${seqNo}/comments`,
160
+ title: "Comments",
161
+ },
162
+ ],
163
+ _actions: await opRunItemActions(orderKey, runNo, seqNo, opRun.id, opRun.operationId, opRun.status, user),
164
+ };
165
+ }
166
+ export async function formatOpRunTransition(orderKey, runNo, user, opRun) {
167
+ const seqNo = opRun.operation.seqNo;
168
+ return {
169
+ id: opRun.id,
170
+ status: opRun.status,
171
+ assignedTo: opRun.assignedTo?.username ?? null,
172
+ cost: opRun.cost,
173
+ note: opRun.statusNote ?? null,
174
+ completedAt: formatDate(opRun.completedAt),
175
+ ...formatAuditFields(opRun),
176
+ _actions: await opRunItemActions(orderKey, runNo, seqNo, opRun.id, opRun.operationId, opRun.status, user),
177
+ };
178
+ }
179
+ function formatListOpRun(opRun) {
180
+ const seqNo = opRun.operation.seqNo;
181
+ return {
182
+ id: opRun.id,
183
+ orderRunId: opRun.orderRunId,
184
+ operationId: opRun.operationId,
185
+ seqNo,
186
+ title: opRun.operation.title,
187
+ description: opRun.operation.description,
188
+ workCenterKey: opRun.operation.workCenter?.key ?? null,
189
+ status: opRun.status,
190
+ assignedTo: opRun.assignedTo?.username ?? null,
191
+ cost: opRun.cost,
192
+ note: opRun.statusNote ?? null,
193
+ completedAt: formatDate(opRun.completedAt),
194
+ ...formatAuditFields(opRun),
195
+ stepCount: opRun._count.stepRuns,
196
+ predecessors: opRun.operation.predecessors.map((d) => ({
197
+ seqNo: d.predecessor.seqNo,
198
+ title: d.predecessor.title,
199
+ })),
200
+ };
201
+ }
202
+ export default function operationRunRoutes(fastify) {
203
+ const app = fastify.withTypeProvider();
204
+ // LIST
205
+ app.get("/", {
206
+ schema: {
207
+ description: "List operation runs for an order run",
208
+ tags: ["Operation Runs"],
209
+ params: RunNoParamsSchema,
210
+ response: {
211
+ 200: OperationRunListResponseSchema,
212
+ 404: ErrorResponseSchema,
213
+ },
214
+ },
215
+ handler: async (request, reply) => {
216
+ const { orderKey, runNo } = request.params;
217
+ const resolved = await resolveOrderRun(orderKey, runNo);
218
+ if (!resolved) {
219
+ return notFound(reply, `Order run not found`);
220
+ }
221
+ const items = await listOpRuns(resolved.run.id);
222
+ return {
223
+ items: items.map((opRun) => formatListOpRun(opRun)),
224
+ total: items.length,
225
+ _links: [selfLink(`/${opRunResource(orderKey, runNo)}`)],
226
+ _linkTemplates: [
227
+ {
228
+ rel: "item",
229
+ hrefTemplate: `${API_PREFIX}/orders/${orderKey}/runs/${runNo}/ops/{seqNo}`,
230
+ },
231
+ ],
232
+ };
233
+ },
234
+ });
235
+ // GET by seqNo
236
+ app.get("/:seqNo", {
237
+ schema: {
238
+ description: "Get a single operation run by operation sequence number",
239
+ tags: ["Operation Runs"],
240
+ params: SeqNoParamsSchema,
241
+ response: {
242
+ 200: OperationRunSchema,
243
+ 404: ErrorResponseSchema,
244
+ },
245
+ },
246
+ handler: async (request, reply) => {
247
+ const { orderKey, runNo, seqNo } = request.params;
248
+ const resolved = await resolveOpRun(orderKey, runNo, seqNo);
249
+ if (!resolved) {
250
+ return notFound(reply, `Operation run not found`);
251
+ }
252
+ const opRun = await getOpRun(resolved.opRun.id);
253
+ if (!opRun) {
254
+ return notFound(reply, `Operation run not found`);
255
+ }
256
+ return formatOpRun(orderKey, runNo, request.erpUser, opRun);
257
+ },
258
+ });
259
+ // UPDATE (pending/in_progress only)
260
+ app.put("/:seqNo", {
261
+ schema: {
262
+ description: "Update an operation run (pending or in_progress status only)",
263
+ tags: ["Operation Runs"],
264
+ params: SeqNoParamsSchema,
265
+ body: UpdateOperationRunSchema,
266
+ response: {
267
+ 200: MutateResponseSchema,
268
+ 404: ErrorResponseSchema,
269
+ 409: ErrorResponseSchema,
270
+ },
271
+ },
272
+ preHandler: requirePermission("order_executor"),
273
+ handler: async (request, reply) => {
274
+ const { orderKey, runNo, seqNo } = request.params;
275
+ const userId = request.erpUser.id;
276
+ const resolved = await resolveOpRun(orderKey, runNo, seqNo);
277
+ if (!resolved)
278
+ return notFound(reply, `Operation run not found`);
279
+ const orderErr = checkOrderRunStarted(resolved.run.status);
280
+ if (orderErr)
281
+ return conflict(reply, orderErr);
282
+ const statusErr = validateStatusFor("update", resolved.opRun.status, [
283
+ OperationRunStatus.pending,
284
+ OperationRunStatus.in_progress,
285
+ ]);
286
+ if (statusErr)
287
+ return conflict(reply, statusErr);
288
+ const opRun = await updateOpRun(resolved.opRun.id, request.body, userId);
289
+ if (wantsFullResponse(request)) {
290
+ useFullSerializer(reply);
291
+ return formatOpRun(orderKey, runNo, request.erpUser, opRun);
292
+ }
293
+ return {
294
+ _actions: await opRunItemActions(orderKey, runNo, seqNo, opRun.id, opRun.operationId, opRun.status, request.erpUser),
295
+ };
296
+ },
297
+ });
298
+ }
299
+ //# sourceMappingURL=operation-runs.js.map
@@ -0,0 +1,283 @@
1
+ import { CreateOperationSchema, ErrorResponseSchema, MutateResponseSchema, OperationListResponseSchema, OperationSchema, RevisionStatus, SeqNoCreateResponseSchema, UpdateOperationSchema, } from "@naisys/erp-shared";
2
+ import { z } from "zod/v4";
3
+ import { hasPermission, requirePermission } from "../auth-middleware.js";
4
+ import { conflict, notFound } from "../error-handler.js";
5
+ import { API_PREFIX, selfLink } from "../hateoas.js";
6
+ import { calcNextSeqNo, childItemLinks, draftCrudActions, formatAuditFields, mutationResult, permGate, resolveRevision, } from "../route-helpers.js";
7
+ import { createOperation, deleteOperation, findExisting, getOperation, listOperations, updateOperation, } from "../services/operation-service.js";
8
+ import { findWorkCenterByKey } from "../services/work-center-service.js";
9
+ /** Resolve an optional workCenterKey to a workCenterId. Returns undefined (skip), null (clear), or the ID. */
10
+ async function resolveWorkCenterId(workCenterKey) {
11
+ if (workCenterKey === undefined)
12
+ return { id: undefined };
13
+ if (workCenterKey === null)
14
+ return { id: null };
15
+ const wc = await findWorkCenterByKey(workCenterKey);
16
+ if (!wc)
17
+ return { error: `Work center '${workCenterKey}' not found` };
18
+ return { id: wc.id };
19
+ }
20
+ const ParamsSchema = z.object({
21
+ orderKey: z.string(),
22
+ revNo: z.coerce.number().int(),
23
+ });
24
+ const OpParamsSchema = z.object({
25
+ orderKey: z.string(),
26
+ revNo: z.coerce.number().int(),
27
+ seqNo: z.coerce.number().int(),
28
+ });
29
+ function opBasePath(orderKey, revNo) {
30
+ return `/orders/${orderKey}/revs/${revNo}/ops`;
31
+ }
32
+ function formatOperation(orderKey, revNo, revStatus, user, operation, summary) {
33
+ const base = opBasePath(orderKey, revNo);
34
+ return {
35
+ id: operation.id,
36
+ orderRevId: operation.orderRevId,
37
+ seqNo: operation.seqNo,
38
+ title: operation.title,
39
+ description: operation.description,
40
+ workCenterKey: operation.workCenter?.key ?? null,
41
+ ...(summary?.stepCount !== undefined
42
+ ? { stepCount: summary.stepCount }
43
+ : {}),
44
+ ...(summary?.stepSummary ? { stepSummary: summary.stepSummary } : {}),
45
+ ...(summary?.predecessors ? { predecessors: summary.predecessors } : {}),
46
+ ...formatAuditFields(operation),
47
+ _links: [
48
+ ...childItemLinks(base, operation.seqNo, "Operations", `/orders/${orderKey}/revs/${revNo}`, "Revision", "Operation"),
49
+ {
50
+ rel: "steps",
51
+ href: `${API_PREFIX}${base}/${operation.seqNo}/steps`,
52
+ title: "Steps",
53
+ },
54
+ {
55
+ rel: "dependencies",
56
+ href: `${API_PREFIX}${base}/${operation.seqNo}/deps`,
57
+ title: "Dependencies",
58
+ },
59
+ ...(operation.workCenter
60
+ ? [
61
+ {
62
+ rel: "workCenter",
63
+ href: `${API_PREFIX}/work-centers/${operation.workCenter.key}`,
64
+ title: "Work Center",
65
+ },
66
+ ]
67
+ : []),
68
+ ],
69
+ _actions: draftCrudActions(`${API_PREFIX}${base}/${operation.seqNo}`, "UpdateOperation", revStatus, user),
70
+ };
71
+ }
72
+ export default function operationRoutes(fastify) {
73
+ const app = fastify.withTypeProvider();
74
+ // LIST
75
+ app.get("/", {
76
+ schema: {
77
+ description: "List operations for a revision",
78
+ tags: ["Operations"],
79
+ params: ParamsSchema,
80
+ response: {
81
+ 200: OperationListResponseSchema,
82
+ 404: ErrorResponseSchema,
83
+ },
84
+ },
85
+ handler: async (request, reply) => {
86
+ const { orderKey, revNo } = request.params;
87
+ const resolved = await resolveRevision(orderKey, revNo);
88
+ if (!resolved) {
89
+ return notFound(reply, `Revision not found`);
90
+ }
91
+ const items = await listOperations(resolved.rev.id);
92
+ const maxSeq = items.length > 0 ? items[items.length - 1].seqNo : 0;
93
+ const user = request.erpUser;
94
+ const base = opBasePath(orderKey, revNo);
95
+ return {
96
+ items: items.map((operation) => {
97
+ const formatted = formatOperation(orderKey, revNo, resolved.rev.status, user, operation, {
98
+ stepCount: operation._count.steps,
99
+ predecessors: operation.predecessors.map((d) => ({
100
+ seqNo: d.predecessor.seqNo,
101
+ title: d.predecessor.title,
102
+ })),
103
+ });
104
+ const { _links, ...rest } = formatted;
105
+ return rest;
106
+ }),
107
+ total: items.length,
108
+ nextSeqNo: calcNextSeqNo(maxSeq),
109
+ _links: [selfLink(base)],
110
+ _linkTemplates: [
111
+ {
112
+ rel: "item",
113
+ hrefTemplate: `${API_PREFIX}/orders/${orderKey}/revs/${revNo}/ops/{seqNo}`,
114
+ },
115
+ ],
116
+ _actions: [
117
+ {
118
+ rel: "create",
119
+ href: `${API_PREFIX}${base}`,
120
+ method: "POST",
121
+ title: "Add Operation",
122
+ schema: `${API_PREFIX}/schemas/CreateOperation`,
123
+ ...(!hasPermission(user, "order_planner")
124
+ ? permGate(false, "order_planner")
125
+ : resolved.rev.status !== RevisionStatus.draft
126
+ ? {
127
+ disabled: true,
128
+ disabledReason: "Can only add operations in draft revisions",
129
+ }
130
+ : {}),
131
+ },
132
+ ],
133
+ };
134
+ },
135
+ });
136
+ // CREATE
137
+ app.post("/", {
138
+ schema: {
139
+ description: "Create an operation for a revision",
140
+ tags: ["Operations"],
141
+ params: ParamsSchema,
142
+ body: CreateOperationSchema,
143
+ response: {
144
+ 201: SeqNoCreateResponseSchema,
145
+ 404: ErrorResponseSchema,
146
+ 409: ErrorResponseSchema,
147
+ },
148
+ },
149
+ preHandler: requirePermission("order_planner"),
150
+ handler: async (request, reply) => {
151
+ const { orderKey, revNo } = request.params;
152
+ const { seqNo: requestedSeqNo, title, description, workCenterKey, predecessorSeqNos, } = request.body;
153
+ const userId = request.erpUser.id;
154
+ const resolved = await resolveRevision(orderKey, revNo);
155
+ if (!resolved) {
156
+ return notFound(reply, `Revision not found`);
157
+ }
158
+ if (resolved.rev.status !== RevisionStatus.draft) {
159
+ return conflict(reply, `Cannot add operations to a ${resolved.rev.status} revision`);
160
+ }
161
+ const wcResult = await resolveWorkCenterId(workCenterKey);
162
+ if ("error" in wcResult) {
163
+ return notFound(reply, wcResult.error);
164
+ }
165
+ const operation = await createOperation(resolved.rev.id, requestedSeqNo, title, description, wcResult.id, predecessorSeqNos, userId);
166
+ const full = formatOperation(orderKey, revNo, resolved.rev.status, request.erpUser, operation, {
167
+ stepCount: operation._count.steps,
168
+ predecessors: operation.predecessors.map((d) => ({
169
+ seqNo: d.predecessor.seqNo,
170
+ title: d.predecessor.title,
171
+ })),
172
+ });
173
+ reply.status(201);
174
+ return mutationResult(request, reply, full, {
175
+ id: full.id,
176
+ seqNo: full.seqNo,
177
+ _links: full._links,
178
+ _actions: full._actions,
179
+ });
180
+ },
181
+ });
182
+ // GET by seqNo
183
+ app.get("/:seqNo", {
184
+ schema: {
185
+ description: "Get an operation by sequence number",
186
+ tags: ["Operations"],
187
+ params: OpParamsSchema,
188
+ response: {
189
+ 200: OperationSchema,
190
+ 404: ErrorResponseSchema,
191
+ },
192
+ },
193
+ handler: async (request, reply) => {
194
+ const { orderKey, revNo, seqNo } = request.params;
195
+ const resolved = await resolveRevision(orderKey, revNo);
196
+ if (!resolved) {
197
+ return notFound(reply, `Revision not found`);
198
+ }
199
+ const operation = await getOperation(resolved.rev.id, seqNo);
200
+ if (!operation) {
201
+ return notFound(reply, `Operation ${seqNo} not found`);
202
+ }
203
+ return formatOperation(orderKey, revNo, resolved.rev.status, request.erpUser, operation, {
204
+ stepCount: operation.steps.length,
205
+ stepSummary: operation.steps.map((s) => ({
206
+ seqNo: s.seqNo,
207
+ title: s.title,
208
+ })),
209
+ });
210
+ },
211
+ });
212
+ // UPDATE (draft only)
213
+ app.put("/:seqNo", {
214
+ schema: {
215
+ description: "Update an operation (draft revision only)",
216
+ tags: ["Operations"],
217
+ params: OpParamsSchema,
218
+ body: UpdateOperationSchema,
219
+ response: {
220
+ 200: MutateResponseSchema,
221
+ 404: ErrorResponseSchema,
222
+ 409: ErrorResponseSchema,
223
+ },
224
+ },
225
+ preHandler: requirePermission("order_planner"),
226
+ handler: async (request, reply) => {
227
+ const { orderKey, revNo, seqNo } = request.params;
228
+ const { title, description, workCenterKey, seqNo: newSeqNo, } = request.body;
229
+ const userId = request.erpUser.id;
230
+ const resolved = await resolveRevision(orderKey, revNo);
231
+ if (!resolved) {
232
+ return notFound(reply, `Revision not found`);
233
+ }
234
+ if (resolved.rev.status !== RevisionStatus.draft) {
235
+ return conflict(reply, `Cannot update operations on a ${resolved.rev.status} revision`);
236
+ }
237
+ const existing = await findExisting(resolved.rev.id, seqNo);
238
+ if (!existing) {
239
+ return notFound(reply, `Operation ${seqNo} not found`);
240
+ }
241
+ const wcResult = await resolveWorkCenterId(workCenterKey);
242
+ if ("error" in wcResult) {
243
+ return notFound(reply, wcResult.error);
244
+ }
245
+ const operation = await updateOperation(existing.id, { title, description, workCenterId: wcResult.id, seqNo: newSeqNo }, userId);
246
+ const full = formatOperation(orderKey, revNo, resolved.rev.status, request.erpUser, operation);
247
+ return mutationResult(request, reply, full, {
248
+ _actions: full._actions,
249
+ });
250
+ },
251
+ });
252
+ // DELETE (draft only)
253
+ app.delete("/:seqNo", {
254
+ schema: {
255
+ description: "Delete an operation (draft revision only)",
256
+ tags: ["Operations"],
257
+ params: OpParamsSchema,
258
+ response: {
259
+ 204: z.void(),
260
+ 404: ErrorResponseSchema,
261
+ 409: ErrorResponseSchema,
262
+ },
263
+ },
264
+ preHandler: requirePermission("order_planner"),
265
+ handler: async (request, reply) => {
266
+ const { orderKey, revNo, seqNo } = request.params;
267
+ const resolved = await resolveRevision(orderKey, revNo);
268
+ if (!resolved) {
269
+ return notFound(reply, `Revision not found`);
270
+ }
271
+ if (resolved.rev.status !== RevisionStatus.draft) {
272
+ return conflict(reply, `Cannot delete operations on a ${resolved.rev.status} revision`);
273
+ }
274
+ const existing = await findExisting(resolved.rev.id, seqNo);
275
+ if (!existing) {
276
+ return notFound(reply, `Operation ${seqNo} not found`);
277
+ }
278
+ await deleteOperation(existing.id);
279
+ reply.status(204);
280
+ },
281
+ });
282
+ }
283
+ //# sourceMappingURL=operations.js.map
@@ -0,0 +1,86 @@
1
+ import { ErrorResponseSchema, OrderRevisionTransitionSchema, RevisionStatus, } from "@naisys/erp-shared";
2
+ import { requirePermission } from "../auth-middleware.js";
3
+ import { conflict, notFound } from "../error-handler.js";
4
+ import { resolveOrder, useFullSerializer, wantsFullResponse, } from "../route-helpers.js";
5
+ import { findExisting, transitionStatus, } from "../services/order-revision-service.js";
6
+ import { formatRevision, revisionItemActions, RevNoParamsSchema, } from "./order-revisions.js";
7
+ export default function orderRevisionTransitionRoutes(fastify) {
8
+ const app = fastify.withTypeProvider();
9
+ // APPROVE (draft → approved)
10
+ app.post("/:revNo/approve", {
11
+ schema: {
12
+ description: "Approve a draft revision",
13
+ tags: ["Order Revisions"],
14
+ params: RevNoParamsSchema,
15
+ response: {
16
+ 200: OrderRevisionTransitionSchema,
17
+ 404: ErrorResponseSchema,
18
+ 409: ErrorResponseSchema,
19
+ },
20
+ },
21
+ preHandler: requirePermission("order_planner"),
22
+ handler: async (request, reply) => {
23
+ const { orderKey, revNo } = request.params;
24
+ const order = await resolveOrder(orderKey);
25
+ if (!order) {
26
+ return notFound(reply, `Order '${orderKey}' not found`);
27
+ }
28
+ const existing = await findExisting(order.id, revNo);
29
+ if (!existing) {
30
+ return notFound(reply, `Revision ${revNo} not found for order '${orderKey}'`);
31
+ }
32
+ if (existing.status !== RevisionStatus.draft) {
33
+ return conflict(reply, `Cannot approve revision in ${existing.status} status`);
34
+ }
35
+ const userId = request.erpUser.id;
36
+ const revision = await transitionStatus(existing.id, "approve", RevisionStatus.draft, RevisionStatus.approved, userId);
37
+ if (wantsFullResponse(request)) {
38
+ useFullSerializer(reply);
39
+ return await formatRevision(orderKey, request.erpUser, revision);
40
+ }
41
+ return {
42
+ status: revision.status,
43
+ _actions: revisionItemActions("orders", orderKey, revNo, revision.status, request.erpUser),
44
+ };
45
+ },
46
+ });
47
+ // OBSOLETE (approved → obsolete)
48
+ app.post("/:revNo/obsolete", {
49
+ schema: {
50
+ description: "Mark an approved revision as obsolete",
51
+ tags: ["Order Revisions"],
52
+ params: RevNoParamsSchema,
53
+ response: {
54
+ 200: OrderRevisionTransitionSchema,
55
+ 404: ErrorResponseSchema,
56
+ 409: ErrorResponseSchema,
57
+ },
58
+ },
59
+ preHandler: requirePermission("order_planner"),
60
+ handler: async (request, reply) => {
61
+ const { orderKey, revNo } = request.params;
62
+ const order = await resolveOrder(orderKey);
63
+ if (!order) {
64
+ return notFound(reply, `Order '${orderKey}' not found`);
65
+ }
66
+ const existing = await findExisting(order.id, revNo);
67
+ if (!existing) {
68
+ return notFound(reply, `Revision ${revNo} not found for order '${orderKey}'`);
69
+ }
70
+ if (existing.status !== RevisionStatus.approved) {
71
+ return conflict(reply, `Cannot mark revision as obsolete from ${existing.status} status`);
72
+ }
73
+ const userId = request.erpUser.id;
74
+ const revision = await transitionStatus(existing.id, "obsolete", RevisionStatus.approved, RevisionStatus.obsolete, userId);
75
+ if (wantsFullResponse(request)) {
76
+ useFullSerializer(reply);
77
+ return await formatRevision(orderKey, request.erpUser, revision);
78
+ }
79
+ return {
80
+ status: revision.status,
81
+ _actions: revisionItemActions("orders", orderKey, revNo, revision.status, request.erpUser),
82
+ };
83
+ },
84
+ });
85
+ }
86
+ //# sourceMappingURL=order-revision-transitions.js.map