@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/api_key/index.js +2 -0
- package/dist/generated/entities/api_key/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/api_keys/data/entities.js +7 -0
- package/dist/modules/api_keys/data/entities.js.map +2 -2
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
- package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
- package/dist/modules/api_keys/services/apiKeyService.js +31 -9
- package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
- package/dist/modules/attachments/lib/ocrService.js +8 -0
- package/dist/modules/attachments/lib/ocrService.js.map +2 -2
- package/dist/modules/auth/api/logout.js +3 -1
- package/dist/modules/auth/api/logout.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +1190 -1408
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +34 -11
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/commands/shared.js +5 -0
- package/dist/modules/sales/commands/shared.js.map +2 -2
- package/dist/modules/sales/commands/shipments.js +9 -0
- package/dist/modules/sales/commands/shipments.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js +6 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
- package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
- package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
- package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
- package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
- package/dist/modules/workflows/lib/instance-status.js +16 -0
- package/dist/modules/workflows/lib/instance-status.js.map +7 -0
- package/dist/modules/workflows/lib/task-handler.js +16 -8
- package/dist/modules/workflows/lib/task-handler.js.map +2 -2
- package/dist/modules/workflows/lib/task-sla.js +10 -0
- package/dist/modules/workflows/lib/task-sla.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +29 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/generated/entities/api_key/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/api_keys/data/entities.ts +25 -1
- package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
- package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
- package/src/modules/api_keys/services/apiKeyService.ts +55 -9
- package/src/modules/attachments/lib/ocrService.ts +8 -0
- package/src/modules/auth/api/logout.ts +3 -1
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/sales/commands/documents.ts +1501 -1626
- package/src/modules/sales/commands/returns.ts +41 -14
- package/src/modules/sales/commands/shared.ts +40 -0
- package/src/modules/sales/commands/shipments.ts +10 -0
- package/src/modules/staff/api/timesheets/time-projects/route.ts +6 -2
- package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
- package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
- package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
- package/src/modules/workflows/i18n/de.json +2 -0
- package/src/modules/workflows/i18n/en.json +2 -0
- package/src/modules/workflows/i18n/es.json +2 -0
- package/src/modules/workflows/i18n/ko.json +2 -0
- package/src/modules/workflows/i18n/pl.json +2 -0
- package/src/modules/workflows/lib/instance-status.ts +29 -0
- package/src/modules/workflows/lib/task-handler.ts +18 -10
- package/src/modules/workflows/lib/task-sla.ts +13 -0
- 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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|