@open-mercato/core 0.10.1-develop.7728.1.6b52eb2849 → 0.10.1-develop.7741.1.61a2652671

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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/api_key/index.js +2 -0
  3. package/dist/generated/entities/api_key/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/api_keys/data/entities.js +7 -0
  7. package/dist/modules/api_keys/data/entities.js.map +2 -2
  8. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
  9. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
  10. package/dist/modules/api_keys/services/apiKeyService.js +31 -9
  11. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  12. package/dist/modules/attachments/lib/ocrService.js +8 -0
  13. package/dist/modules/attachments/lib/ocrService.js.map +2 -2
  14. package/dist/modules/auth/api/logout.js +3 -1
  15. package/dist/modules/auth/api/logout.js.map +2 -2
  16. package/dist/modules/sales/commands/documents.js +1190 -1408
  17. package/dist/modules/sales/commands/documents.js.map +2 -2
  18. package/dist/modules/sales/commands/returns.js +34 -11
  19. package/dist/modules/sales/commands/returns.js.map +2 -2
  20. package/dist/modules/sales/commands/shared.js +5 -0
  21. package/dist/modules/sales/commands/shared.js.map +2 -2
  22. package/dist/modules/sales/commands/shipments.js +9 -0
  23. package/dist/modules/sales/commands/shipments.js.map +2 -2
  24. package/dist/modules/staff/api/timesheets/time-projects/route.js +6 -2
  25. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  26. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  27. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  28. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  29. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  30. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  31. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  32. package/dist/modules/workflows/lib/instance-status.js +16 -0
  33. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  34. package/dist/modules/workflows/lib/task-handler.js +16 -8
  35. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  36. package/dist/modules/workflows/lib/task-sla.js +10 -0
  37. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  38. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  39. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  40. package/generated/entities/api_key/index.ts +1 -0
  41. package/generated/entity-fields-registry.ts +1 -0
  42. package/package.json +7 -7
  43. package/src/modules/api_keys/data/entities.ts +25 -1
  44. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  45. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  46. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  47. package/src/modules/attachments/lib/ocrService.ts +8 -0
  48. package/src/modules/auth/api/logout.ts +3 -1
  49. package/src/modules/auth/i18n/de.json +3 -0
  50. package/src/modules/auth/i18n/en.json +3 -0
  51. package/src/modules/auth/i18n/es.json +3 -0
  52. package/src/modules/auth/i18n/ko.json +3 -0
  53. package/src/modules/auth/i18n/pl.json +3 -0
  54. package/src/modules/sales/commands/documents.ts +1501 -1626
  55. package/src/modules/sales/commands/returns.ts +41 -14
  56. package/src/modules/sales/commands/shared.ts +40 -0
  57. package/src/modules/sales/commands/shipments.ts +10 -0
  58. package/src/modules/staff/api/timesheets/time-projects/route.ts +6 -2
  59. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  60. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  61. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  62. package/src/modules/workflows/i18n/de.json +2 -0
  63. package/src/modules/workflows/i18n/en.json +2 -0
  64. package/src/modules/workflows/i18n/es.json +2 -0
  65. package/src/modules/workflows/i18n/ko.json +2 -0
  66. package/src/modules/workflows/i18n/pl.json +2 -0
  67. package/src/modules/workflows/lib/instance-status.ts +29 -0
  68. package/src/modules/workflows/lib/task-handler.ts +18 -10
  69. package/src/modules/workflows/lib/task-sla.ts +13 -0
  70. package/src/modules/workflows/lib/workflow-executor.ts +44 -1
@@ -15,7 +15,7 @@ import type { SalesCalculationService } from '../services/salesCalculationServic
15
15
  import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
16
16
  import { isExternalAmountsMode } from '../lib/calculations'
17
17
  import { readPersistedHeaderTotals } from '../lib/externalAmounts'
18
- import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
18
+ import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, lockSalesDocumentRow, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
19
19
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
20
20
  import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
21
21
  import { mapOrderLineEntityToSnapshot } from '../lib/lineSnapshots'
@@ -317,6 +317,21 @@ async function loadReturnHeaderSnapshot(em: EntityManager, id: string): Promise<
317
317
  }
318
318
  }
319
319
 
320
+ async function lockReturnOrder(
321
+ em: EntityManager,
322
+ orderId: string,
323
+ scope: { tenantId: string; organizationId: string },
324
+ ): Promise<SalesOrder | null> {
325
+ if (!(await lockSalesDocumentRow(em, 'sales_orders', orderId, scope))) return null
326
+ return findOneWithDecryption(
327
+ em,
328
+ SalesOrder,
329
+ { id: orderId, deletedAt: null, ...scope },
330
+ { refresh: true },
331
+ scope,
332
+ )
333
+ }
334
+
320
335
  /**
321
336
  * Reverse the order-level effects of a return: restore each order line's
322
337
  * `returnedQuantity`, drop the return's line-scoped credit adjustments, remove
@@ -337,20 +352,17 @@ async function reverseReturnEffects(
337
352
  salesCalculationService: SalesCalculationService,
338
353
  snapshot: ReturnSnapshot,
339
354
  ): Promise<void> {
340
- const order = await findOneWithDecryption(
341
- em,
342
- SalesOrder,
343
- { id: snapshot.orderId, deletedAt: null },
344
- {},
345
- { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
346
- )
347
- if (!order) return
348
-
355
+ let order: SalesOrder | null = null
349
356
  let lines: SalesOrderLine[] = []
350
357
  await withAtomicFlush(
351
358
  em,
352
359
  [
353
360
  async () => {
361
+ order = await lockReturnOrder(em, snapshot.orderId, {
362
+ tenantId: snapshot.tenantId,
363
+ organizationId: snapshot.organizationId,
364
+ })
365
+ if (!order) return
354
366
  lines = await findWithDecryption(
355
367
  em,
356
368
  SalesOrderLine,
@@ -369,6 +381,7 @@ async function reverseReturnEffects(
369
381
  })
370
382
  },
371
383
  async () => {
384
+ if (!order) return
372
385
  if (snapshot.adjustmentIds.length) {
373
386
  const adjustments = await findWithDecryption(
374
387
  em,
@@ -450,17 +463,24 @@ async function restoreReturnEffects(
450
463
  em,
451
464
  [
452
465
  async () => {
453
- const order = await findOneWithDecryption(
466
+ const existingOrder = await findOneWithDecryption(
454
467
  em,
455
468
  SalesOrder,
456
469
  { id: snapshot.orderId, deletedAt: null },
457
470
  {},
458
471
  { tenantId: snapshot.tenantId, organizationId: snapshot.organizationId },
459
472
  )
473
+ if (!existingOrder) {
474
+ throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
475
+ }
476
+ ensureSameScope(existingOrder, snapshot.organizationId, snapshot.tenantId)
477
+ const order = await lockReturnOrder(em, existingOrder.id, {
478
+ tenantId: snapshot.tenantId,
479
+ organizationId: snapshot.organizationId,
480
+ })
460
481
  if (!order) {
461
482
  throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
462
483
  }
463
- ensureSameScope(order, snapshot.organizationId, snapshot.tenantId)
464
484
 
465
485
  const orderLines = await findWithDecryption(
466
486
  em,
@@ -643,17 +663,24 @@ const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string
643
663
 
644
664
  const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
645
665
  const { header, createdLines, order } = await em.transactional(async (tx) => {
646
- const order = await findOneWithDecryption(
666
+ const existingOrder = await findOneWithDecryption(
647
667
  tx,
648
668
  SalesOrder,
649
669
  { id: input.orderId, deletedAt: null },
650
670
  {},
651
671
  { tenantId: input.tenantId, organizationId: input.organizationId },
652
672
  )
673
+ if (!existingOrder) {
674
+ throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
675
+ }
676
+ ensureSameScope(existingOrder, input.organizationId, input.tenantId)
677
+ const order = await lockReturnOrder(tx, existingOrder.id, {
678
+ tenantId: input.tenantId,
679
+ organizationId: input.organizationId,
680
+ })
653
681
  if (!order) {
654
682
  throw notFound(translate('sales.returns.orderMissing', 'Order not found.'))
655
683
  }
656
- ensureSameScope(order, input.organizationId, input.tenantId)
657
684
  await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER)
658
685
 
659
686
  const orderLines = await findWithDecryption(
@@ -49,6 +49,46 @@ export async function enforceSalesDocumentOptimisticLock(
49
49
  })
50
50
  }
51
51
 
52
+ type SalesDocumentLockTable = 'sales_orders' | 'sales_quotes'
53
+ type SalesDocumentLockRow = {
54
+ id: string
55
+ tenant_id: string
56
+ organization_id: string
57
+ deleted_at: Date | null
58
+ }
59
+ type SalesDocumentLockDatabase = Record<SalesDocumentLockTable, SalesDocumentLockRow>
60
+
61
+ /**
62
+ * Take the parent-document row lock that serializes every write deriving the
63
+ * order/quote header (totals, line count, paid amounts) or the document's child
64
+ * rows (lines, adjustments, returned/fulfilled quantities) inside the caller's
65
+ * transaction. Acquire it BEFORE reading the child rows the header is
66
+ * recalculated from, and before locking those child rows, so all writers use the
67
+ * same parent-then-children lock order (#6463).
68
+ *
69
+ * `FOR NO KEY UPDATE` conflicts with other writers of the same document, including
70
+ * the `FOR UPDATE` locks taken by payments and undo, while still allowing the
71
+ * `KEY SHARE` locks that foreign-key inserts (for example shipments) take on the
72
+ * parent row. Returns false when no live row matches the scoped id.
73
+ */
74
+ export async function lockSalesDocumentRow(
75
+ em: EntityManager,
76
+ table: SalesDocumentLockTable,
77
+ id: string,
78
+ scope: { tenantId: string; organizationId: string },
79
+ ): Promise<boolean> {
80
+ const locked = await em.getKysely<SalesDocumentLockDatabase>()
81
+ .selectFrom(table)
82
+ .select('id')
83
+ .where('id', '=', id)
84
+ .where('tenant_id', '=', scope.tenantId)
85
+ .where('organization_id', '=', scope.organizationId)
86
+ .where('deleted_at', 'is', null)
87
+ .forNoKeyUpdate()
88
+ .executeTakeFirst()
89
+ return Boolean(locked)
90
+ }
91
+
52
92
  export { cloneJson } from '../lib/json'
53
93
 
54
94
  export function toNumericString(value: number | null | undefined): string | null {
@@ -31,6 +31,7 @@ import {
31
31
  ensureTenantScope,
32
32
  extractUndoPayload,
33
33
  enforceSalesDocumentOptimisticLock,
34
+ lockSalesDocumentRow,
34
35
  SALES_RESOURCE_KIND_ORDER,
35
36
  } from './shared'
36
37
  import { resolveDictionaryEntryValue } from '../lib/dictionaries'
@@ -312,8 +313,16 @@ async function deleteShipmentWithItems(em: EntityManager, shipment: SalesShipmen
312
313
  await em.flush()
313
314
  }
314
315
 
316
+ async function lockOrderBeforeLines(em: EntityManager, order: SalesOrder): Promise<void> {
317
+ await lockSalesDocumentRow(em, 'sales_orders', order.id, {
318
+ tenantId: order.tenantId,
319
+ organizationId: order.organizationId,
320
+ })
321
+ }
322
+
315
323
  async function recomputeFulfilledQuantities(em: EntityManager, order: SalesOrder): Promise<void> {
316
324
  const scope = { tenantId: order.tenantId, organizationId: order.organizationId }
325
+ await lockOrderBeforeLines(em, order)
317
326
  const shipments = await findWithDecryption(em, SalesShipment, { order, deletedAt: null }, {}, scope)
318
327
  const shipmentIds = shipments.map((entry) => entry.id)
319
328
  const shipmentItems = shipmentIds.length
@@ -386,6 +395,7 @@ async function validateShipmentItems(params: {
386
395
  throw new CrudHttpError(400, { error: translate('sales.shipments.items_required', 'Add at least one line to ship.') })
387
396
  }
388
397
  const scope = { tenantId: order.tenantId, organizationId: order.organizationId }
398
+ if (lockOrderLines) await lockOrderBeforeLines(em, order)
389
399
  const orderLines = await findWithDecryption(
390
400
  em,
391
401
  SalesOrderLine,
@@ -379,9 +379,13 @@ const crud = makeCrudRoute({
379
379
  .filter((value) => value.length > 0)
380
380
  }
381
381
  if (parseBooleanFlag(query.mine) && ctx.auth) {
382
+ const tenantId = ctx.auth.tenantId ?? null
383
+ const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
384
+ if (!tenantId || !organizationId) {
385
+ filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }
386
+ return filters
387
+ }
382
388
  const em = ctx.container.resolve('em') as EntityManager
383
- const tenantId = ctx.auth.tenantId ?? ''
384
- const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? ''
385
389
  const assigned = await resolveCallerAssignedProjectIds(
386
390
  em,
387
391
  ctx.auth.sub,
@@ -128,7 +128,11 @@ export async function POST(request: NextRequest, { params }: { params: { id: str
128
128
  // Anything the handler refuses on ownership or status grounds collapses into
129
129
  // the same 404 the predicate would have produced, so the act path discloses
130
130
  // no more than the read path.
131
- if (code === 'TASK_NOT_FOUND' || code === 'TASK_ASSIGNED_TO_ANOTHER_USER') {
131
+ if (
132
+ code === 'TASK_NOT_FOUND' ||
133
+ code === 'TASK_ASSIGNED_TO_ANOTHER_USER' ||
134
+ code === 'WORKFLOW_NOT_ACTIVE'
135
+ ) {
132
136
  return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })
133
137
  }
134
138
  logger.error('Error completing portal task', { err: error })
@@ -164,6 +164,12 @@ export async function POST(
164
164
  { status: 409 }
165
165
  )
166
166
  }
167
+ if ((error as { code?: unknown }).code === 'WORKFLOW_NOT_ACTIVE') {
168
+ return NextResponse.json(
169
+ { error: error.message, code: 'WORKFLOW_NOT_ACTIVE' },
170
+ { status: 409 }
171
+ )
172
+ }
167
173
  if (error.message.includes('not found')) {
168
174
  return NextResponse.json(
169
175
  { error: error.message },
@@ -213,7 +219,7 @@ export const openApi: OpenApiRouteDoc = {
213
219
  { status: 401, description: 'Unauthorized', schema: workflowErrorSchema },
214
220
  { status: 403, description: 'The task is visible to the caller but is not theirs to act on — it has no assignee and no role queue, so it must be reassigned first (§6.4: administration widens seeing, never acting).', schema: workflowErrorSchema },
215
221
  { status: 404, description: 'Task not found, or not visible to the caller', schema: workflowErrorSchema },
216
- { status: 409, description: 'Task already completed', schema: workflowErrorSchema },
222
+ { status: 409, description: 'Task already completed, assigned to another user, or its workflow run is no longer active (`code: WORKFLOW_NOT_ACTIVE` — the run is completed, failed, cancelled, compensating or compensated)', schema: workflowErrorSchema },
217
223
  { status: 500, description: 'Internal server error', schema: workflowErrorSchema },
218
224
  ],
219
225
  },
@@ -222,8 +222,12 @@ export default function UserTaskDetailPage({ params }: { params: { id: string }
222
222
  flash(t('workflows.tasks.messages.completed'), 'success')
223
223
  refreshTask()
224
224
  } else {
225
- const failure = result.result as { error?: string } | null
226
- flash(failure?.error || t('workflows.tasks.messages.completeFailed'), 'error')
225
+ const failure = result.result as { error?: string; code?: string } | null
226
+ const failureMessage =
227
+ failure?.code === 'WORKFLOW_NOT_ACTIVE'
228
+ ? t('workflows.tasks.messages.workflowNotActive')
229
+ : failure?.error || t('workflows.tasks.messages.completeFailed')
230
+ flash(failureMessage, 'error')
227
231
  }
228
232
  } catch (err) {
229
233
  logger.error('Error completing task', { err })
@@ -729,6 +729,7 @@
729
729
  "workflows.events.types.TransitionFired": "Übergang ausgelöst",
730
730
  "workflows.events.types.TransitionPausedForActivities": "Übergang für Aktivitäten pausiert",
731
731
  "workflows.events.types.USER_TASK_ASSIGNED": "Benutzeraufgabe zugewiesen",
732
+ "workflows.events.types.USER_TASK_CANCELLED": "Benutzeraufgabe abgebrochen",
732
733
  "workflows.events.types.USER_TASK_CLAIMED": "Benutzeraufgabe übernommen",
733
734
  "workflows.events.types.USER_TASK_COMPLETED": "Benutzeraufgabe abgeschlossen",
734
735
  "workflows.events.types.USER_TASK_CREATED": "Benutzeraufgabe erstellt",
@@ -1851,6 +1852,7 @@
1851
1852
  "workflows.tasks.messages.loadFailed": "Benutzeraufgaben konnten nicht geladen werden",
1852
1853
  "workflows.tasks.messages.reassignFailed": "Aufgabe konnte nicht neu zugewiesen werden",
1853
1854
  "workflows.tasks.messages.reassigned": "Aufgabe erfolgreich neu zugewiesen",
1855
+ "workflows.tasks.messages.workflowNotActive": "Der Workflow ist nicht mehr aktiv",
1854
1856
  "workflows.tasks.myTasks": "Meine Aufgaben",
1855
1857
  "workflows.tasks.overdue": "Überfällig",
1856
1858
  "workflows.tasks.plural": "Benutzeraufgaben",
@@ -729,6 +729,7 @@
729
729
  "workflows.events.types.TransitionFired": "Transition Fired",
730
730
  "workflows.events.types.TransitionPausedForActivities": "Transition Paused for Activities",
731
731
  "workflows.events.types.USER_TASK_ASSIGNED": "User Task Assigned",
732
+ "workflows.events.types.USER_TASK_CANCELLED": "User Task Cancelled",
732
733
  "workflows.events.types.USER_TASK_CLAIMED": "User Task Claimed",
733
734
  "workflows.events.types.USER_TASK_COMPLETED": "User Task Completed",
734
735
  "workflows.events.types.USER_TASK_CREATED": "User Task Created",
@@ -1851,6 +1852,7 @@
1851
1852
  "workflows.tasks.messages.loadFailed": "Failed to load user tasks",
1852
1853
  "workflows.tasks.messages.reassignFailed": "Failed to reassign task",
1853
1854
  "workflows.tasks.messages.reassigned": "Task reassigned successfully",
1855
+ "workflows.tasks.messages.workflowNotActive": "Workflow is no longer active",
1854
1856
  "workflows.tasks.myTasks": "My Tasks",
1855
1857
  "workflows.tasks.overdue": "Overdue",
1856
1858
  "workflows.tasks.plural": "User Tasks",
@@ -729,6 +729,7 @@
729
729
  "workflows.events.types.TransitionFired": "Transicion activada",
730
730
  "workflows.events.types.TransitionPausedForActivities": "Transicion pausada por actividades",
731
731
  "workflows.events.types.USER_TASK_ASSIGNED": "Tarea de usuario asignada",
732
+ "workflows.events.types.USER_TASK_CANCELLED": "Tarea de usuario cancelada",
732
733
  "workflows.events.types.USER_TASK_CLAIMED": "Tarea de usuario reclamada",
733
734
  "workflows.events.types.USER_TASK_COMPLETED": "Tarea de usuario completada",
734
735
  "workflows.events.types.USER_TASK_CREATED": "Tarea de usuario creada",
@@ -1851,6 +1852,7 @@
1851
1852
  "workflows.tasks.messages.loadFailed": "No se pudieron cargar las tareas de usuario",
1852
1853
  "workflows.tasks.messages.reassignFailed": "No se pudo reasignar la tarea",
1853
1854
  "workflows.tasks.messages.reassigned": "Tarea reasignada correctamente",
1855
+ "workflows.tasks.messages.workflowNotActive": "El flujo de trabajo ya no está activo",
1854
1856
  "workflows.tasks.myTasks": "Mis tareas",
1855
1857
  "workflows.tasks.overdue": "Vencida",
1856
1858
  "workflows.tasks.plural": "Tareas de usuario",
@@ -729,6 +729,7 @@
729
729
  "workflows.events.types.TransitionFired": "전환 발동됨",
730
730
  "workflows.events.types.TransitionPausedForActivities": "활동 대기로 전환 일시 중지",
731
731
  "workflows.events.types.USER_TASK_ASSIGNED": "사용자 작업 할당됨",
732
+ "workflows.events.types.USER_TASK_CANCELLED": "사용자 작업 취소됨",
732
733
  "workflows.events.types.USER_TASK_CLAIMED": "사용자 작업 수령됨",
733
734
  "workflows.events.types.USER_TASK_COMPLETED": "사용자 작업 완료",
734
735
  "workflows.events.types.USER_TASK_CREATED": "사용자 작업 생성됨",
@@ -1851,6 +1852,7 @@
1851
1852
  "workflows.tasks.messages.loadFailed": "사용자 작업을 불러오지 못했습니다",
1852
1853
  "workflows.tasks.messages.reassignFailed": "Failed to reassign task",
1853
1854
  "workflows.tasks.messages.reassigned": "Task reassigned successfully",
1855
+ "workflows.tasks.messages.workflowNotActive": "워크플로가 더 이상 활성 상태가 아닙니다",
1854
1856
  "workflows.tasks.myTasks": "내 작업",
1855
1857
  "workflows.tasks.overdue": "기한 초과",
1856
1858
  "workflows.tasks.plural": "사용자 작업",
@@ -729,6 +729,7 @@
729
729
  "workflows.events.types.TransitionFired": "Przejście Wykonane",
730
730
  "workflows.events.types.TransitionPausedForActivities": "Przejście Wstrzymane dla Działań",
731
731
  "workflows.events.types.USER_TASK_ASSIGNED": "Zadanie Użytkownika Przypisane",
732
+ "workflows.events.types.USER_TASK_CANCELLED": "Zadanie Użytkownika Anulowane",
732
733
  "workflows.events.types.USER_TASK_CLAIMED": "Zadanie Użytkownika Przejęte",
733
734
  "workflows.events.types.USER_TASK_COMPLETED": "Zadanie Użytkownika Ukończone",
734
735
  "workflows.events.types.USER_TASK_CREATED": "Zadanie Użytkownika Utworzone",
@@ -1851,6 +1852,7 @@
1851
1852
  "workflows.tasks.messages.loadFailed": "Nie udało się załadować zadań użytkowników",
1852
1853
  "workflows.tasks.messages.reassignFailed": "Nie udało się ponownie przypisać zadania",
1853
1854
  "workflows.tasks.messages.reassigned": "Zadanie zostało ponownie przypisane",
1855
+ "workflows.tasks.messages.workflowNotActive": "Przepływ pracy nie jest już aktywny",
1854
1856
  "workflows.tasks.myTasks": "Moje Zadania",
1855
1857
  "workflows.tasks.overdue": "Po Terminie",
1856
1858
  "workflows.tasks.plural": "Zadania Użytkownika",
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Workflow instance statuses a user-task completion or a user-task SLA job must
3
+ * not touch.
4
+ *
5
+ * PURE: a type-only import, so the engine does not depend on the metrics
6
+ * module's `WORKFLOW_TERMINAL_STATUSES` for a runtime decision.
7
+ *
8
+ * The name means "a completion must not touch this run", NOT "its tasks are
9
+ * closed": a FAILED run keeps its open task so a retry can pick it up.
10
+ * `COMPENSATING` is included although it is not terminal — `compensateWorkflow`
11
+ * holds it while rollback activities run and then overwrites it with
12
+ * `COMPENSATED` or `FAILED`, and a completion in that window would advance a
13
+ * run that is being rolled back.
14
+ */
15
+
16
+ import type { WorkflowInstanceStatus } from '../data/entities'
17
+
18
+ export const RUN_STATUSES_CLOSED_TO_USER_TASKS: readonly WorkflowInstanceStatus[] = [
19
+ 'COMPLETED',
20
+ 'FAILED',
21
+ 'CANCELLED',
22
+ 'COMPENSATING',
23
+ 'COMPENSATED',
24
+ ]
25
+
26
+ export function isRunClosedToUserTasks(status: string | null | undefined): boolean {
27
+ if (!status) return false
28
+ return (RUN_STATUSES_CLOSED_TO_USER_TASKS as readonly string[]).includes(status)
29
+ }
@@ -27,6 +27,7 @@ import {
27
27
  } from './task-decisions'
28
28
  import { loadTaskDecisionContext } from './task-decision-context'
29
29
  import { readRequiredFormFields } from './task-form-schema'
30
+ import { isRunClosedToUserTasks } from './instance-status'
30
31
  import * as stepHandler from './step-handler'
31
32
  import * as transitionHandler from './transition-handler'
32
33
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -174,6 +175,23 @@ export async function completeUserTask(
174
175
  )
175
176
  }
176
177
 
178
+ const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)
179
+ if (!instance) {
180
+ throw new UserTaskError(
181
+ 'Workflow instance not found',
182
+ 'INSTANCE_NOT_FOUND',
183
+ { workflowInstanceId: task.workflowInstanceId }
184
+ )
185
+ }
186
+
187
+ if (isRunClosedToUserTasks(instance.status)) {
188
+ throw new UserTaskError(
189
+ 'Workflow is no longer active',
190
+ 'WORKFLOW_NOT_ACTIVE',
191
+ { taskId, workflowInstanceId: instance.id, status: instance.status }
192
+ )
193
+ }
194
+
177
195
  // The chosen decision rides along inside the completion payload the task
178
196
  // already persists — no new column, and downstream route conditions can read
179
197
  // it once the payload is merged into the run context.
@@ -190,16 +208,6 @@ export async function completeUserTask(
190
208
 
191
209
  await em.flush()
192
210
 
193
- // Fetch workflow instance
194
- const instance = await em.findOne(WorkflowInstance, task.workflowInstanceId)
195
- if (!instance) {
196
- throw new UserTaskError(
197
- 'Workflow instance not found',
198
- 'INSTANCE_NOT_FOUND',
199
- { workflowInstanceId: task.workflowInstanceId }
200
- )
201
- }
202
-
203
211
  // Branch-scoped completion: when the task belongs to a parallel branch,
204
212
  // merge form data into the branch namespace and resume just that branch.
205
213
  //
@@ -32,6 +32,7 @@ import {
32
32
  type AgentReviewStepLike,
33
33
  } from './agent-review'
34
34
  import { findDefinitionForInstance } from './find-definition'
35
+ import { isRunClosedToUserTasks } from './instance-status'
35
36
  import { emitWorkflowsEvent } from '../events'
36
37
  import type * as stepHandlerModule from './step-handler'
37
38
  import type * as transitionHandlerModule from './transition-handler'
@@ -300,7 +301,9 @@ type BreachOutcome =
300
301
  | 'reassigned'
301
302
  | 'routed'
302
303
  | 'route_skipped_branch'
304
+ | 'route_skipped_closed_run'
303
305
  | 'attention'
306
+ | 'attention_skipped_closed_run'
304
307
 
305
308
  interface AppliedBreachHandling {
306
309
  outcome: BreachOutcome
@@ -393,6 +396,9 @@ async function applyBreachHandling(
393
396
 
394
397
  if (resolution.kind === 'attention') {
395
398
  if (!instance) return { outcome: 'none' }
399
+ if (isRunClosedToUserTasks(instance.status)) {
400
+ return { outcome: 'attention_skipped_closed_run', detail: { stepId, status: instance.status } }
401
+ }
396
402
  await markInstanceForAttention(em, instance, stepId, task, now)
397
403
  return { outcome: 'attention', detail: { stepId } }
398
404
  }
@@ -430,6 +436,13 @@ async function applyBreachHandling(
430
436
  return { outcome: 'route_skipped_branch', detail: { transitionId: resolution.transition.transitionId } }
431
437
  }
432
438
 
439
+ if (isRunClosedToUserTasks(instance?.status)) {
440
+ return {
441
+ outcome: 'route_skipped_closed_run',
442
+ detail: { transitionId: resolution.transition.transitionId, status: instance?.status },
443
+ }
444
+ }
445
+
433
446
  // The task is superseded by the route, not abandoned: `ESCALATED` is an
434
447
  // existing `UserTaskStatus`, so no state machine changes.
435
448
  task.status = 'ESCALATED'
@@ -13,8 +13,9 @@
13
13
  import { EntityManager, LockMode } from '@mikro-orm/core'
14
14
  import type { EntityManager as PostgreSqlEntityManager } from '@mikro-orm/postgresql'
15
15
  import type { AwilixContainer } from 'awilix'
16
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
16
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
17
17
  import {
18
+ UserTask,
18
19
  WorkflowDefinition,
19
20
  WorkflowInstance,
20
21
  WorkflowEvent,
@@ -1487,6 +1488,28 @@ export async function completeWorkflow(
1487
1488
  }
1488
1489
 
1489
1490
  // Original completion logic (no compensation needed or status is COMPLETED/CANCELLED)
1491
+
1492
+ // A cancelled run has no recovery path, so its open tasks can never become
1493
+ // actionable again: close them in the same flush as the status. CANCELLED
1494
+ // only — a FAILED run parked on a task is retried against that same task.
1495
+ // Loaded before the run is mutated so no read sits between a scalar change
1496
+ // and the flush that persists it.
1497
+ const cancelledTasks =
1498
+ status === 'CANCELLED'
1499
+ ? await findWithDecryption(
1500
+ em as PostgreSqlEntityManager,
1501
+ UserTask,
1502
+ {
1503
+ workflowInstanceId: instance.id,
1504
+ tenantId: instance.tenantId,
1505
+ organizationId: instance.organizationId,
1506
+ status: { $in: ['PENDING', 'IN_PROGRESS'] },
1507
+ },
1508
+ undefined,
1509
+ { tenantId: instance.tenantId, organizationId: instance.organizationId }
1510
+ )
1511
+ : []
1512
+
1490
1513
  const now = new Date()
1491
1514
  instance.status = status
1492
1515
  instance.updatedAt = now
@@ -1514,8 +1537,27 @@ export async function completeWorkflow(
1514
1537
 
1515
1538
  await writeRunOutcome(em, instance, status)
1516
1539
 
1540
+ for (const task of cancelledTasks) {
1541
+ task.status = 'CANCELLED'
1542
+ task.updatedAt = now
1543
+ }
1544
+
1517
1545
  await em.flush()
1518
1546
 
1547
+ // Logged before the terminal event so WORKFLOW_CANCELLED stays last in the
1548
+ // run's timeline.
1549
+ for (const task of cancelledTasks) {
1550
+ await logWorkflowEvent(em, {
1551
+ workflowInstanceId: instanceId,
1552
+ stepInstanceId: task.stepInstanceId,
1553
+ ...(task.branchInstanceId ? { branchInstanceId: task.branchInstanceId } : {}),
1554
+ eventType: 'USER_TASK_CANCELLED',
1555
+ eventData: { taskId: task.id, taskName: task.taskName, reason: 'workflow-cancelled' },
1556
+ tenantId: instance.tenantId,
1557
+ organizationId: instance.organizationId,
1558
+ })
1559
+ }
1560
+
1519
1561
  // Log completion event
1520
1562
  const eventType =
1521
1563
  status === 'COMPLETED'
@@ -2044,6 +2086,7 @@ async function logWorkflowEvent(
2044
2086
  event: {
2045
2087
  workflowInstanceId: string
2046
2088
  stepInstanceId?: string
2089
+ branchInstanceId?: string
2047
2090
  eventType: string
2048
2091
  eventData: any
2049
2092
  userId?: string