@naisys/erp 3.0.0-beta.50 → 3.0.0-beta.52

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.
@@ -225,6 +225,7 @@ export const OperationRunScalarFieldEnum = {
225
225
  status: 'status',
226
226
  assignedToId: 'assignedToId',
227
227
  cost: 'cost',
228
+ tokens: 'tokens',
228
229
  statusNote: 'statusNote',
229
230
  completedAt: 'completedAt',
230
231
  createdAt: 'createdAt',
@@ -315,6 +316,7 @@ export const LaborTicketScalarFieldEnum = {
315
316
  clockIn: 'clockIn',
316
317
  clockOut: 'clockOut',
317
318
  cost: 'cost',
319
+ tokens: 'tokens',
318
320
  createdAt: 'createdAt',
319
321
  createdById: 'createdById',
320
322
  updatedAt: 'updatedAt',
@@ -4,7 +4,7 @@ import { requirePermission } from "../../middleware/auth-middleware.js";
4
4
  import { checkOrderRunStarted, checkWorkCenterAccess, mutationResult, resolveOpRun, } from "../../route-helpers.js";
5
5
  import { checkPredecessorsComplete, checkStepsComplete, reblockSuccessors, transitionStatus, unblockSuccessors, validateStatusFor, } from "../../services/operations/operation-run-service.js";
6
6
  import { transitionStatus as transitionOrderRunStatus } from "../../services/orders/order-run-service.js";
7
- import { clockIn, clockOutAllForOpRun, isUserClockedIn, sumLaborTicketCosts, } from "../../services/production/labor-ticket-service.js";
7
+ import { clockIn, clockOutAllForOpRun, isUserClockedIn, sumLaborTicketMetrics, } from "../../services/production/labor-ticket-service.js";
8
8
  import { formatOpRunTransition, SeqNoParamsSchema } from "./operation-runs.js";
9
9
  export default function operationRunTransitionRoutes(fastify) {
10
10
  const app = fastify.withTypeProvider();
@@ -99,10 +99,11 @@ export default function operationRunTransitionRoutes(fastify) {
99
99
  if (stepsErr)
100
100
  return unprocessable(reply, stepsErr);
101
101
  await clockOutAllForOpRun(resolved.opRun.id, userId);
102
- const cost = await sumLaborTicketCosts(resolved.opRun.id);
102
+ const { cost, tokens } = await sumLaborTicketMetrics(resolved.opRun.id);
103
103
  const opRun = await transitionStatus(resolved.opRun.id, "complete", OperationRunStatus.in_progress, OperationRunStatus.completed, userId, {
104
104
  completedAt: new Date(),
105
105
  cost,
106
+ tokens,
106
107
  statusNote: note ?? null,
107
108
  });
108
109
  await unblockSuccessors(resolved.run.id, resolved.opRun.operationId, userId);
@@ -153,8 +154,12 @@ export default function operationRunTransitionRoutes(fastify) {
153
154
  if (resolved.opRun.status === OperationRunStatus.in_progress) {
154
155
  await clockOutAllForOpRun(resolved.opRun.id, userId);
155
156
  }
156
- const cost = await sumLaborTicketCosts(resolved.opRun.id);
157
- const opRun = await transitionStatus(resolved.opRun.id, "skip", resolved.opRun.status, OperationRunStatus.skipped, userId, { ...(cost > 0 ? { cost } : undefined), statusNote: note ?? null });
157
+ const { cost, tokens } = await sumLaborTicketMetrics(resolved.opRun.id);
158
+ const opRun = await transitionStatus(resolved.opRun.id, "skip", resolved.opRun.status, OperationRunStatus.skipped, userId, {
159
+ ...(cost > 0 ? { cost } : undefined),
160
+ ...(tokens > 0 ? { tokens } : undefined),
161
+ statusNote: note ?? null,
162
+ });
158
163
  await unblockSuccessors(resolved.run.id, resolved.opRun.operationId, userId);
159
164
  const full = await formatOpRunTransition(orderKey, runNo, request.erpUser, opRun);
160
165
  return mutationResult(request, reply, full, {
@@ -196,8 +201,12 @@ export default function operationRunTransitionRoutes(fastify) {
196
201
  if (statusErr)
197
202
  return conflict(reply, statusErr);
198
203
  await clockOutAllForOpRun(resolved.opRun.id, userId);
199
- const cost = await sumLaborTicketCosts(resolved.opRun.id);
200
- const opRun = await transitionStatus(resolved.opRun.id, "fail", OperationRunStatus.in_progress, OperationRunStatus.failed, userId, { ...(cost > 0 ? { cost } : undefined), statusNote: note ?? null });
204
+ const { cost, tokens } = await sumLaborTicketMetrics(resolved.opRun.id);
205
+ const opRun = await transitionStatus(resolved.opRun.id, "fail", OperationRunStatus.in_progress, OperationRunStatus.failed, userId, {
206
+ ...(cost > 0 ? { cost } : undefined),
207
+ ...(tokens > 0 ? { tokens } : undefined),
208
+ statusNote: note ?? null,
209
+ });
201
210
  const full = await formatOpRunTransition(orderKey, runNo, request.erpUser, opRun);
202
211
  return mutationResult(request, reply, full, {
203
212
  status: opRun.status,
@@ -5,16 +5,25 @@ import { API_PREFIX, selfLink } from "../../hateoas.js";
5
5
  import { hasPermission, requirePermission, } from "../../middleware/auth-middleware.js";
6
6
  import { checkOrderRunStarted, checkWorkCenterAccess, childItemLinks, formatAuditFields, formatDate, resolveActions, resolveOpRun, resolveOrderRun, useFullSerializer, wantsFullResponse, } from "../../route-helpers.js";
7
7
  import { checkStepsComplete, getOpRun, getOpRunFieldRefSummary, getOpRunStepSummary, listOpRuns, updateOpRun, validateStatusFor, } from "../../services/operations/operation-run-service.js";
8
+ import { isUserClockedIn } from "../../services/production/labor-ticket-service.js";
8
9
  function opRunResource(orderKey, runNo) {
9
10
  return `orders/${orderKey}/runs/${runNo}/ops`;
10
11
  }
11
12
  async function opRunItemActions(orderKey, runNo, seqNo, opRunId, operationId, status, user) {
12
13
  const href = `${API_PREFIX}/${opRunResource(orderKey, runNo)}/${seqNo}`;
13
14
  const isExecutor = hasPermission(user, "order_executor");
14
- const stepsErr = isExecutor && status === OperationRunStatus.in_progress
15
- ? await checkStepsComplete(opRunId)
16
- : null;
15
+ const isInProgress = status === OperationRunStatus.in_progress;
16
+ const stepsErr = isExecutor && isInProgress ? await checkStepsComplete(opRunId) : null;
17
17
  const wcErr = user ? await checkWorkCenterAccess(operationId, user) : null;
18
+ // Completing an op requires the caller to be clocked in (enforced by the
19
+ // /complete transition). Surface clock-in and the requirement here so an
20
+ // agent inspecting the op run finds it without following the `labor` link.
21
+ const userClockedIn = isExecutor && isInProgress && user
22
+ ? await isUserClockedIn(opRunId, user.id)
23
+ : false;
24
+ const notClockedInErr = isExecutor && isInProgress && !userClockedIn
25
+ ? "You must be clocked in to complete an operation"
26
+ : null;
18
27
  return resolveActions([
19
28
  {
20
29
  rel: "assign",
@@ -50,6 +59,18 @@ async function opRunItemActions(orderKey, runNo, seqNo, opRunId, operationId, st
50
59
  ? "Operation is blocked by incomplete predecessors"
51
60
  : null),
52
61
  },
62
+ {
63
+ rel: "clock-in",
64
+ path: "/labor/clock-in",
65
+ method: "POST",
66
+ title: "Clock In",
67
+ permission: "order_executor",
68
+ statuses: [OperationRunStatus.in_progress],
69
+ disabledWhen: () => wcErr ??
70
+ (userClockedIn
71
+ ? "You are already clocked in to this operation"
72
+ : null),
73
+ },
53
74
  {
54
75
  rel: "update",
55
76
  method: "PUT",
@@ -68,7 +89,7 @@ async function opRunItemActions(orderKey, runNo, seqNo, opRunId, operationId, st
68
89
  body: { note: "" },
69
90
  permission: "order_executor",
70
91
  statuses: [OperationRunStatus.in_progress],
71
- disabledWhen: () => wcErr ?? stepsErr,
92
+ disabledWhen: () => wcErr ?? notClockedInErr ?? stepsErr,
72
93
  },
73
94
  {
74
95
  rel: "skip",
@@ -136,6 +157,7 @@ export async function formatOpRun(orderKey, runNo, user, opRun) {
136
157
  status: opRun.status,
137
158
  assignedTo: opRun.assignedTo?.username ?? null,
138
159
  cost: opRun.cost,
160
+ tokens: opRun.tokens,
139
161
  note: opRun.statusNote ?? null,
140
162
  completedAt: formatDate(opRun.completedAt),
141
163
  stepSummary: stepSummaryRows.map((sr) => ({
@@ -182,6 +204,7 @@ export async function formatOpRunTransition(orderKey, runNo, user, opRun) {
182
204
  status: opRun.status,
183
205
  assignedTo: opRun.assignedTo?.username ?? null,
184
206
  cost: opRun.cost,
207
+ tokens: opRun.tokens,
185
208
  note: opRun.statusNote ?? null,
186
209
  completedAt: formatDate(opRun.completedAt),
187
210
  ...formatAuditFields(opRun),
@@ -203,6 +226,7 @@ function formatListOpRun(opRun) {
203
226
  status: opRun.status,
204
227
  assignedTo: opRun.assignedTo?.username ?? null,
205
228
  cost: opRun.cost,
229
+ tokens: opRun.tokens,
206
230
  note: opRun.statusNote ?? null,
207
231
  completedAt: formatDate(opRun.completedAt),
208
232
  ...formatAuditFields(opRun),
@@ -92,6 +92,7 @@ function formatLaborTicket(orderKey, runNo, seqNo, ticket) {
92
92
  clockIn: ticket.clockIn.toISOString(),
93
93
  clockOut: formatDate(ticket.clockOut),
94
94
  cost: ticket.cost,
95
+ tokens: ticket.tokens,
95
96
  ...formatAuditFields(ticket),
96
97
  _links: [
97
98
  selfLink(`/${laborResource(orderKey, runNo, seqNo)}/${ticket.id}`),
@@ -1,3 +1,4 @@
1
+ import { keyBy } from "@naisys/common";
1
2
  import { OperationRunStatus as OperationRunStatusValues, } from "@naisys/erp-shared";
2
3
  import { fieldTypeString, getValueFormatHint, } from "@naisys/erp-shared";
3
4
  import erpDb from "../../database/erpDb.js";
@@ -153,7 +154,7 @@ export async function getOpRunFieldRefSummary(operationId, orderRunId, orderKey,
153
154
  },
154
155
  },
155
156
  });
156
- const stepRunMap = new Map(stepRuns.map((sr) => [sr.stepId, sr]));
157
+ const stepRunMap = keyBy(stepRuns, (sr) => sr.stepId);
157
158
  return fieldRefs.map((ref) => {
158
159
  const sr = stepRunMap.get(ref.sourceStep.id);
159
160
  const storedFieldValues = sr?.fieldRecord?.fieldValues ?? [];
@@ -1,3 +1,4 @@
1
+ import { mapDefined } from "@naisys/common";
1
2
  import { RevisionStatus as RevisionStatusValues, } from "@naisys/erp-shared";
2
3
  import erpDb from "../../database/erpDb.js";
3
4
  import { includeUsers } from "../../route-helpers.js";
@@ -230,9 +231,7 @@ export async function deleteRevision(id) {
230
231
  where: { operationId: { in: opIds } },
231
232
  select: { id: true, fieldSetId: true },
232
233
  });
233
- const fieldSetIds = steps
234
- .map((s) => s.fieldSetId)
235
- .filter((id) => id !== null);
234
+ const fieldSetIds = mapDefined(steps, (s) => s.fieldSetId);
236
235
  // Steps reference field_sets via FK, so delete steps first
237
236
  await erpTx.step.deleteMany({
238
237
  where: { operationId: { in: opIds } },
@@ -1,3 +1,4 @@
1
+ import { keyBy, mapDefined } from "@naisys/common";
1
2
  import { OperationRunStatus as OperationRunStatusValues, OrderRunStatus as OrderRunStatusValues, } from "@naisys/erp-shared";
2
3
  import erpDb from "../../database/erpDb.js";
3
4
  import { writeAuditEntry } from "../audit.js";
@@ -188,9 +189,7 @@ export async function deleteOrderRun(id) {
188
189
  where: { operationRunId: { in: opRunIds } },
189
190
  select: { fieldRecordId: true },
190
191
  });
191
- const fieldRecordIds = stepRuns
192
- .map((s) => s.fieldRecordId)
193
- .filter((id) => id !== null);
192
+ const fieldRecordIds = mapDefined(stepRuns, (s) => s.fieldRecordId);
194
193
  if (fieldRecordIds.length > 0) {
195
194
  await tx.fieldValue.deleteMany({
196
195
  where: { fieldRecordId: { in: fieldRecordIds } },
@@ -294,8 +293,8 @@ export async function completeOrderRun(orderRunId, orderId, data, userId) {
294
293
  };
295
294
  }
296
295
  const itemFields = order.item.fieldSet?.fields ?? [];
297
- const fieldsBySeqNo = new Map(itemFields.map((f) => [f.seqNo, f]));
298
- const fieldsById = new Map(itemFields.map((f) => [f.id, f]));
296
+ const fieldsBySeqNo = keyBy(itemFields, (f) => f.seqNo);
297
+ const fieldsById = keyBy(itemFields, (f) => f.id);
299
298
  // Validate caller-supplied fieldSeqNos exist on the item.
300
299
  const callerValues = data.fieldValues ?? [];
301
300
  for (const fv of callerValues) {
@@ -1,3 +1,4 @@
1
+ import { keyBy, unique } from "@naisys/common";
1
2
  import erpDb from "../../database/erpDb.js";
2
3
  // --- Prisma deep include for full revision tree ---
3
4
  const includeFullTree = {
@@ -41,9 +42,9 @@ function compareProps(pairs) {
41
42
  return changes;
42
43
  }
43
44
  function diffFields(fromFields, toFields) {
44
- const fromMap = new Map(fromFields.map((f) => [f.seqNo, f]));
45
- const toMap = new Map(toFields.map((f) => [f.seqNo, f]));
46
- const allSeqNos = new Set([...fromMap.keys(), ...toMap.keys()]);
45
+ const fromMap = keyBy(fromFields, (f) => f.seqNo);
46
+ const toMap = keyBy(toFields, (f) => f.seqNo);
47
+ const allSeqNos = unique([...fromMap.keys(), ...toMap.keys()]);
47
48
  const result = [];
48
49
  for (const seqNo of [...allSeqNos].sort((a, b) => a - b)) {
49
50
  const from = fromMap.get(seqNo);
@@ -72,9 +73,9 @@ function diffFields(fromFields, toFields) {
72
73
  return result;
73
74
  }
74
75
  function diffSteps(fromSteps, toSteps) {
75
- const fromMap = new Map(fromSteps.map((s) => [s.seqNo, s]));
76
- const toMap = new Map(toSteps.map((s) => [s.seqNo, s]));
77
- const allSeqNos = new Set([...fromMap.keys(), ...toMap.keys()]);
76
+ const fromMap = keyBy(fromSteps, (s) => s.seqNo);
77
+ const toMap = keyBy(toSteps, (s) => s.seqNo);
78
+ const allSeqNos = unique([...fromMap.keys(), ...toMap.keys()]);
78
79
  const result = [];
79
80
  for (const seqNo of [...allSeqNos].sort((a, b) => a - b)) {
80
81
  const from = fromMap.get(seqNo);
@@ -138,9 +139,9 @@ function diffDeps(fromDeps, toDeps) {
138
139
  return result;
139
140
  }
140
141
  function diffOperations(fromOps, toOps) {
141
- const fromMap = new Map(fromOps.map((op) => [op.seqNo, op]));
142
- const toMap = new Map(toOps.map((op) => [op.seqNo, op]));
143
- const allSeqNos = new Set([...fromMap.keys(), ...toMap.keys()]);
142
+ const fromMap = keyBy(fromOps, (op) => op.seqNo);
143
+ const toMap = keyBy(toOps, (op) => op.seqNo);
144
+ const allSeqNos = unique([...fromMap.keys(), ...toMap.keys()]);
144
145
  const result = [];
145
146
  for (const seqNo of [...allSeqNos].sort((a, b) => a - b)) {
146
147
  const from = fromMap.get(seqNo);
@@ -0,0 +1,67 @@
1
+ import { sumAgentMetricsByUuid } from "@naisys/hub-database";
2
+ import { OperationRunStatus } from "@naisys/erp-shared";
3
+ import erpDb from "../../database/erpDb.js";
4
+ /**
5
+ * Populate `tokens` on labor tickets and operation runs finalized before the
6
+ * column existed. Idempotent — only touches rows where `tokens IS NULL`, and
7
+ * only for agent users (others can't have tokens, so their rows stay NULL
8
+ * and the next startup ignores them too). Requires the hub DB client.
9
+ */
10
+ export async function backfillOpRunTokens() {
11
+ const tickets = await erpDb.laborTicket.findMany({
12
+ where: {
13
+ tokens: null,
14
+ clockOut: { not: null },
15
+ operationRun: {
16
+ status: {
17
+ in: [
18
+ OperationRunStatus.completed,
19
+ OperationRunStatus.skipped,
20
+ OperationRunStatus.failed,
21
+ ],
22
+ },
23
+ },
24
+ user: { isAgent: true },
25
+ },
26
+ select: {
27
+ id: true,
28
+ operationRunId: true,
29
+ clockIn: true,
30
+ clockOut: true,
31
+ user: { select: { uuid: true } },
32
+ },
33
+ });
34
+ if (tickets.length === 0)
35
+ return;
36
+ const dirtyOpRunIds = new Set();
37
+ for (const ticket of tickets) {
38
+ const { tokens } = await sumAgentMetricsByUuid(ticket.user.uuid, ticket.clockIn, ticket.clockOut);
39
+ await erpDb.laborTicket.update({
40
+ where: { id: ticket.id },
41
+ data: { tokens: Math.round(tokens) },
42
+ });
43
+ dirtyOpRunIds.add(ticket.operationRunId);
44
+ }
45
+ // Re-aggregate touched op_runs. Skip rows that already have a snapshot so
46
+ // we never clobber a value set by the normal transition path.
47
+ for (const opRunId of dirtyOpRunIds) {
48
+ const opRun = await erpDb.operationRun.findUnique({
49
+ where: { id: opRunId },
50
+ select: { tokens: true },
51
+ });
52
+ if (!opRun || opRun.tokens !== null)
53
+ continue;
54
+ const agg = await erpDb.laborTicket.aggregate({
55
+ where: { operationRunId: opRunId },
56
+ _sum: { tokens: true },
57
+ });
58
+ const total = agg._sum.tokens ?? 0;
59
+ if (total > 0) {
60
+ await erpDb.operationRun.update({
61
+ where: { id: opRunId },
62
+ data: { tokens: total },
63
+ });
64
+ }
65
+ }
66
+ }
67
+ //# sourceMappingURL=labor-ticket-backfill.js.map
@@ -1,4 +1,4 @@
1
- import { getLatestRunInfoByUuid, sumCostsByUuid } from "@naisys/hub-database";
1
+ import { getLatestRunInfoByUuid, sumAgentMetricsByUuid, } from "@naisys/hub-database";
2
2
  import erpDb from "../../database/erpDb.js";
3
3
  import { writeAuditEntry } from "../audit.js";
4
4
  // --- Prisma include & result type ---
@@ -9,19 +9,22 @@ export const includeLaborTicket = {
9
9
  };
10
10
  // --- Helpers ---
11
11
  /**
12
- * Compute the cost for a labor ticket at clock-out time.
12
+ * Compute cost + tokens for a labor ticket at clock-out time.
13
13
  * Agents: sum of hub cost entries for the user within the clock-in/out window.
14
- * Non-agents: 0.
14
+ * Non-agents: zeros.
15
15
  */
16
- async function computeCost(userId, clockIn, clockOut) {
16
+ async function computeAgentMetrics(userId, clockIn, clockOut) {
17
17
  const user = await erpDb.user.findUnique({
18
18
  where: { id: userId },
19
19
  select: { isAgent: true, uuid: true },
20
20
  });
21
21
  if (!user?.isAgent)
22
- return 0;
23
- const cost = await sumCostsByUuid(user.uuid, clockIn, clockOut);
24
- return Math.round(cost * 100) / 100;
22
+ return { cost: 0, tokens: 0 };
23
+ const { cost, tokens } = await sumAgentMetricsByUuid(user.uuid, clockIn, clockOut);
24
+ return {
25
+ cost: Math.round(cost * 100) / 100,
26
+ tokens: Math.round(tokens),
27
+ };
25
28
  }
26
29
  /**
27
30
  * Get the current hub run info (run_id + session start) for an agent user.
@@ -67,10 +70,10 @@ export async function clockIn(operationRunId, userId, actorId) {
67
70
  where: { userId, clockOut: null },
68
71
  });
69
72
  for (const ticket of openTickets) {
70
- const cost = await computeCost(userId, ticket.clockIn, now);
73
+ const { cost, tokens } = await computeAgentMetrics(userId, ticket.clockIn, now);
71
74
  await tx.laborTicket.update({
72
75
  where: { id: ticket.id },
73
- data: { clockOut: now, cost, updatedById: actorId },
76
+ data: { clockOut: now, cost, tokens, updatedById: actorId },
74
77
  });
75
78
  }
76
79
  // If no tickets were auto-closed and this is the first ticket for the
@@ -118,10 +121,10 @@ export async function clockOut(operationRunId, opts, actorId) {
118
121
  const openTickets = await tx.laborTicket.findMany({ where });
119
122
  const updated = [];
120
123
  for (const ticket of openTickets) {
121
- const cost = await computeCost(ticket.userId, ticket.clockIn, now);
124
+ const { cost, tokens } = await computeAgentMetrics(ticket.userId, ticket.clockIn, now);
122
125
  const result = await tx.laborTicket.update({
123
126
  where: { id: ticket.id },
124
- data: { clockOut: now, cost, updatedById: actorId },
127
+ data: { clockOut: now, cost, tokens, updatedById: actorId },
125
128
  include: includeLaborTicket,
126
129
  });
127
130
  updated.push(result);
@@ -132,12 +135,15 @@ export async function clockOut(operationRunId, opts, actorId) {
132
135
  export async function clockOutAllForOpRun(operationRunId, actorId) {
133
136
  await clockOut(operationRunId, {}, actorId);
134
137
  }
135
- export async function sumLaborTicketCosts(operationRunId) {
138
+ export async function sumLaborTicketMetrics(operationRunId) {
136
139
  const result = await erpDb.laborTicket.aggregate({
137
140
  where: { operationRunId },
138
- _sum: { cost: true },
141
+ _sum: { cost: true, tokens: true },
139
142
  });
140
- return Math.round((result._sum.cost ?? 0) * 100) / 100;
143
+ return {
144
+ cost: Math.round((result._sum.cost ?? 0) * 100) / 100,
145
+ tokens: result._sum.tokens ?? 0,
146
+ };
141
147
  }
142
148
  export async function deleteLaborTicket(ticketId, actorId) {
143
149
  await erpDb.$transaction(async (tx) => {