@open-mercato/core 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7532.1.8eac7e198f

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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/logout.js +6 -1
  3. package/dist/modules/auth/api/logout.js.map +2 -2
  4. package/dist/modules/auth/api/session/refresh.js +6 -1
  5. package/dist/modules/auth/api/session/refresh.js.map +2 -2
  6. package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
  7. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
  8. package/dist/modules/catalog/ai-tools/types.js.map +2 -2
  9. package/dist/modules/customer_accounts/api/login.js +14 -7
  10. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  11. package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
  12. package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
  13. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
  14. package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
  15. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
  16. package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
  17. package/dist/modules/forms/api/form-submissions/route.js +16 -1
  18. package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
  19. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
  20. package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
  21. package/dist/modules/forms/api/runtime-helpers.js +16 -0
  22. package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
  23. package/dist/modules/forms/data/validators.js.map +2 -2
  24. package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
  25. package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
  26. package/dist/modules/forms/lib/runtime-principal.js +27 -10
  27. package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
  28. package/dist/modules/forms/services/distribution-service.js +35 -13
  29. package/dist/modules/forms/services/distribution-service.js.map +2 -2
  30. package/dist/modules/forms/services/submission-service.js +4 -0
  31. package/dist/modules/forms/services/submission-service.js.map +2 -2
  32. package/dist/modules/staff/commands/leave-requests.js +54 -24
  33. package/dist/modules/staff/commands/leave-requests.js.map +2 -2
  34. package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
  35. package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
  36. package/dist/modules/warranty_claims/commands/claims.js +1 -1
  37. package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
  38. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
  39. package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
  40. package/dist/modules/wms/commands/inventory-actions.js +19 -3
  41. package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
  42. package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
  43. package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
  44. package/dist/modules/workflows/lib/parallel-handler.js +20 -14
  45. package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
  46. package/dist/modules/workflows/lib/workflow-executor.js +21 -23
  47. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  48. package/package.json +7 -7
  49. package/src/modules/auth/api/logout.ts +6 -1
  50. package/src/modules/auth/api/session/refresh.ts +6 -1
  51. package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
  52. package/src/modules/catalog/ai-tools/types.ts +1 -0
  53. package/src/modules/customer_accounts/api/login.ts +15 -7
  54. package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
  55. package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
  56. package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
  57. package/src/modules/forms/api/form-submissions/route.ts +21 -2
  58. package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
  59. package/src/modules/forms/api/runtime-helpers.ts +16 -0
  60. package/src/modules/forms/data/validators.ts +2 -1
  61. package/src/modules/forms/i18n/de.json +1 -0
  62. package/src/modules/forms/i18n/en.json +1 -0
  63. package/src/modules/forms/i18n/es.json +1 -0
  64. package/src/modules/forms/i18n/ko.json +1 -0
  65. package/src/modules/forms/i18n/pl.json +1 -0
  66. package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
  67. package/src/modules/forms/lib/runtime-principal.ts +35 -11
  68. package/src/modules/forms/services/distribution-service.ts +35 -13
  69. package/src/modules/forms/services/submission-service.ts +12 -0
  70. package/src/modules/staff/commands/leave-requests.ts +54 -23
  71. package/src/modules/staff/i18n/de.json +2 -0
  72. package/src/modules/staff/i18n/en.json +2 -0
  73. package/src/modules/staff/i18n/es.json +2 -0
  74. package/src/modules/staff/i18n/ko.json +2 -0
  75. package/src/modules/staff/i18n/pl.json +2 -0
  76. package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
  77. package/src/modules/warranty_claims/commands/claims.ts +1 -1
  78. package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
  79. package/src/modules/wms/commands/inventory-actions.ts +30 -10
  80. package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
  81. package/src/modules/workflows/lib/parallel-handler.ts +24 -15
  82. package/src/modules/workflows/lib/workflow-executor.ts +24 -27
@@ -12,7 +12,7 @@ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
12
12
  import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
13
13
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
14
  import { PlannerAvailabilityRule } from '@open-mercato/core/modules/planner/data/entities'
15
- import { StaffLeaveRequest, type StaffLeaveRequestStatus } from '../data/entities'
15
+ import { StaffLeaveRequest, StaffTeamMember, type StaffLeaveRequestStatus } from '../data/entities'
16
16
  import {
17
17
  staffLeaveRequestCreateSchema,
18
18
  staffLeaveRequestDecisionSchema,
@@ -210,6 +210,36 @@ function ensurePendingStatus(request: StaffLeaveRequest): void {
210
210
  }
211
211
  }
212
212
 
213
+ async function ensureUndoablePending(request: StaffLeaveRequest): Promise<void> {
214
+ if (request.status === 'pending') return
215
+ const { translate } = await resolveTranslations()
216
+ throw new CrudHttpError(409, {
217
+ error: translate(
218
+ 'staff.leaveRequests.errors.undoAfterDecision',
219
+ 'This leave request has already been approved or rejected and can no longer be undone. Undo the decision first.',
220
+ ),
221
+ })
222
+ }
223
+
224
+ async function requireUndoMember(em: EntityManager, snapshot: LeaveRequestSnapshot): Promise<StaffTeamMember> {
225
+ const scope = explicitStaffCommandScope(snapshot.tenantId, snapshot.organizationId)
226
+ const member = await findOneWithDecryption(
227
+ em,
228
+ StaffTeamMember,
229
+ applyScopeToWhere<StaffTeamMember>({ id: snapshot.memberId, deletedAt: null }, scope),
230
+ undefined,
231
+ scopeForDecryption(scope),
232
+ )
233
+ if (member) return member
234
+ const { translate } = await resolveTranslations()
235
+ throw new CrudHttpError(409, {
236
+ error: translate(
237
+ 'staff.leaveRequests.errors.undoMemberMissing',
238
+ 'The original team member of this leave request no longer exists, so the change cannot be undone.',
239
+ ),
240
+ })
241
+ }
242
+
213
243
  async function createUnavailabilityRules(params: {
214
244
  em: EntityManager
215
245
  tenantId: string
@@ -388,25 +418,25 @@ const createLeaveRequestCommand: CommandHandler<StaffLeaveRequestCreateInput, {
388
418
  if (!after) return
389
419
  const em = (ctx.container.resolve('em') as EntityManager).fork()
390
420
  const request = await em.findOne(StaffLeaveRequest, scopedStaffSnapshotWhere(after.id, staffSnapshotScopeFromSnapshot(after)))
391
- if (request) {
392
- request.deletedAt = new Date()
393
- request.updatedAt = new Date()
394
- await em.flush()
421
+ if (!request || request.deletedAt) return
422
+ await ensureUndoablePending(request)
423
+ request.deletedAt = new Date()
424
+ request.updatedAt = new Date()
425
+ await em.flush()
395
426
 
396
- const de = (ctx.container.resolve('dataEngine') as DataEngine)
397
- await emitCrudUndoSideEffects({
398
- dataEngine: de,
399
- action: 'deleted',
400
- entity: request,
401
- identifiers: {
402
- id: request.id,
403
- organizationId: request.organizationId,
404
- tenantId: request.tenantId,
405
- },
406
- events: staffLeaveRequestCrudEvents,
427
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
428
+ await emitCrudUndoSideEffects({
429
+ dataEngine: de,
430
+ action: 'deleted',
431
+ entity: request,
432
+ identifiers: {
433
+ id: request.id,
434
+ organizationId: request.organizationId,
435
+ tenantId: request.tenantId,
436
+ },
437
+ events: staffLeaveRequestCrudEvents,
407
438
  indexer: leaveRequestCrudIndexer,
408
- })
409
- }
439
+ })
410
440
  },
411
441
  redo: makeCreateRedo<StaffLeaveRequest, LeaveRequestSnapshot, StaffLeaveRequestCreateInput, { requestId: string }>({
412
442
  entityClass: StaffLeaveRequest,
@@ -517,17 +547,18 @@ const updateLeaveRequestCommand: CommandHandler<StaffLeaveRequestUpdateInput, {
517
547
  if (!before || !after) return
518
548
  const em = (ctx.container.resolve('em') as EntityManager).fork()
519
549
  const request = await em.findOne(StaffLeaveRequest, scopedStaffSnapshotWhere(after.id, staffSnapshotScopeFromSnapshot(after)))
520
- if (!request) return
550
+ if (!request || request.deletedAt) return
551
+ await ensureUndoablePending(request)
552
+ const currentMemberId = typeof request.member === 'string' ? request.member : request.member.id
553
+ if (before.memberId !== currentMemberId) {
554
+ request.member = await requireUndoMember(em, before)
555
+ }
521
556
  request.startDate = new Date(before.startDate)
522
557
  request.endDate = new Date(before.endDate)
523
558
  request.timezone = before.timezone
524
- request.status = before.status
525
559
  request.unavailabilityReasonEntryId = before.unavailabilityReasonEntryId
526
560
  request.unavailabilityReasonValue = before.unavailabilityReasonValue
527
561
  request.note = before.note
528
- request.decisionComment = before.decisionComment
529
- request.decidedByUserId = before.decidedByUserId
530
- request.decidedAt = before.decidedAt ? new Date(before.decidedAt) : null
531
562
  request.updatedAt = new Date()
532
563
  await em.flush()
533
564
 
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "Profil konnte nicht geladen werden.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Bitte zuerst dein Profil erstellen.",
271
271
  "staff.leaveRequests.errors.reject": "Urlaubsantrag konnte nicht abgelehnt werden.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "Dieser Urlaubsantrag wurde bereits genehmigt oder abgelehnt und kann nicht mehr rückgängig gemacht werden. Mache zuerst die Entscheidung rückgängig.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "Das ursprüngliche Teammitglied dieses Urlaubsantrags existiert nicht mehr, daher kann die Änderung nicht rückgängig gemacht werden.",
272
274
  "staff.leaveRequests.form.actions.create": "Erstellen",
273
275
  "staff.leaveRequests.form.actions.save": "Speichern",
274
276
  "staff.leaveRequests.form.createTitle": "Urlaubsantrag erstellen",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "Failed to load your profile.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Create your profile first.",
271
271
  "staff.leaveRequests.errors.reject": "Failed to reject leave request.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "This leave request has already been approved or rejected and can no longer be undone. Undo the decision first.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "The original team member of this leave request no longer exists, so the change cannot be undone.",
272
274
  "staff.leaveRequests.form.actions.create": "Create",
273
275
  "staff.leaveRequests.form.actions.save": "Save",
274
276
  "staff.leaveRequests.form.createTitle": "Create leave request",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "No se pudo cargar tu perfil.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Crea tu perfil primero.",
271
271
  "staff.leaveRequests.errors.reject": "No se pudo rechazar la solicitud de ausencia.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "Esta solicitud de ausencia ya fue aprobada o rechazada y ya no se puede deshacer. Deshaz primero la decisión.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "El miembro del equipo original de esta solicitud de ausencia ya no existe, por lo que el cambio no se puede deshacer.",
272
274
  "staff.leaveRequests.form.actions.create": "Crear",
273
275
  "staff.leaveRequests.form.actions.save": "Guardar",
274
276
  "staff.leaveRequests.form.createTitle": "Crear solicitud de ausencia",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "프로필을 불러오지 못했습니다.",
270
270
  "staff.leaveRequests.errors.profileRequired": "먼저 프로필을 생성하세요.",
271
271
  "staff.leaveRequests.errors.reject": "휴가 신청을 반려하지 못했습니다.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "이 휴가 신청은 이미 승인 또는 반려되어 더 이상 실행 취소할 수 없습니다. 먼저 결정을 실행 취소하세요.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "이 휴가 신청의 원래 팀원이 더 이상 존재하지 않아 변경 사항을 실행 취소할 수 없습니다.",
272
274
  "staff.leaveRequests.form.actions.create": "생성",
273
275
  "staff.leaveRequests.form.actions.save": "저장",
274
276
  "staff.leaveRequests.form.createTitle": "휴가 신청 생성",
@@ -269,6 +269,8 @@
269
269
  "staff.leaveRequests.errors.profileLoad": "Nie udało się załadować profilu.",
270
270
  "staff.leaveRequests.errors.profileRequired": "Najpierw utwórz swój profil.",
271
271
  "staff.leaveRequests.errors.reject": "Nie udało się odrzucić wniosku urlopowego.",
272
+ "staff.leaveRequests.errors.undoAfterDecision": "Ten wniosek urlopowy został już zatwierdzony lub odrzucony i nie można go już cofnąć. Najpierw cofnij decyzję.",
273
+ "staff.leaveRequests.errors.undoMemberMissing": "Pierwotny członek zespołu tego wniosku urlopowego już nie istnieje, więc nie można cofnąć zmiany.",
272
274
  "staff.leaveRequests.form.actions.create": "Utwórz",
273
275
  "staff.leaveRequests.form.actions.save": "Zapisz",
274
276
  "staff.leaveRequests.form.createTitle": "Utwórz wniosek urlopowy",
@@ -17,7 +17,11 @@ import { checkAttachmentAccess } from '@open-mercato/core/modules/attachments/li
17
17
  import { attachmentCrudEvents, attachmentCrudIndexer } from '@open-mercato/core/modules/attachments/lib/crud'
18
18
  import { readAttachmentMetadata } from '@open-mercato/core/modules/attachments/lib/metadata'
19
19
  import { clearAttachmentThumbnailCache } from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
20
- import { isMultipartRequestWithinUploadLimit } from '@open-mercato/core/modules/attachments/lib/upload-limits'
20
+ import {
21
+ isMultipartUploadLimitError,
22
+ parseMultipartFormDataWithinUploadLimit,
23
+ resolveDefaultAttachmentMaxUploadBytes,
24
+ } from '@open-mercato/core/modules/attachments/lib/upload-limits'
21
25
  import {
22
26
  isScopedAttachmentUploadError,
23
27
  type ScopedAttachmentUploadErrorCode,
@@ -341,6 +345,14 @@ export async function GET(req: Request) {
341
345
  })
342
346
  }
343
347
 
348
+ function uploadTooLargeResponse(t: Awaited<ReturnType<typeof resolveTranslations>>['t']): Response {
349
+ return NextResponse.json({
350
+ ok: false,
351
+ error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
352
+ code: 'max_upload_size',
353
+ }, { status: 413 })
354
+ }
355
+
344
356
  async function handleUpload(req: Request, replacement: boolean): Promise<Response> {
345
357
  const contextOrResponse = await resolvePortalContext(req)
346
358
  if (contextOrResponse instanceof Response) return contextOrResponse
@@ -350,14 +362,13 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
350
362
  if (!contentType.toLowerCase().includes('multipart/form-data')) {
351
363
  return NextResponse.json({ ok: false, error: translate('warranty_claims.errors.invalidInput', 'Invalid input') }, { status: 400 })
352
364
  }
353
- if (!isMultipartRequestWithinUploadLimit(req.headers.get('content-length'))) {
354
- return NextResponse.json({
355
- ok: false,
356
- error: t('attachments.errors.maxUploadSize', 'Attachment exceeds the maximum upload size.'),
357
- }, { status: 413 })
365
+ let form: FormData
366
+ try {
367
+ form = await parseMultipartFormDataWithinUploadLimit(req)
368
+ } catch (error) {
369
+ if (isMultipartUploadLimitError(error)) return uploadTooLargeResponse(t)
370
+ throw error
358
371
  }
359
-
360
- const form = await req.formData()
361
372
  const parsed = uploadBodySchema.safeParse({
362
373
  claimId: form.get('claimId') ?? undefined,
363
374
  file: form.get('file') instanceof File ? (form.get('file') as File).name : '',
@@ -376,6 +387,7 @@ async function handleUpload(req: Request, replacement: boolean): Promise<Respons
376
387
  if (!(file instanceof File)) {
377
388
  return NextResponse.json({ ok: false, error: translate('warranty_claims.portal.detail.fileRequired', 'Choose a file before uploading.') }, { status: 400 })
378
389
  }
390
+ if (file.size > resolveDefaultAttachmentMaxUploadBytes()) return uploadTooLargeResponse(t)
379
391
  const buffer = Buffer.from(await file.arrayBuffer())
380
392
 
381
393
  const attachmentId = replacement && parsedAttachmentId?.success ? parsedAttachmentId.data : null
@@ -269,7 +269,7 @@ function hasOwn(input: object, key: string): boolean {
269
269
  }
270
270
 
271
271
  function resolveScope(ctx: CommandRuntimeContext, input: ScopeInput): WarrantyClaimScope {
272
- const tenantId = input.tenantId ?? ctx.auth?.tenantId ?? null
272
+ const tenantId = input.tenantId ?? ctx.auth?.tenantId ?? ctx.organizationScope?.tenantId ?? null
273
273
  const organizationId = input.organizationId ?? ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
274
274
  if (!tenantId) throw new CrudHttpError(400, { error: '[internal] tenant scope required for warranty claim command' })
275
275
  if (!organizationId) throw new CrudHttpError(400, { error: '[internal] organization scope required for warranty claim command' })
@@ -7,7 +7,6 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
7
7
  import type { TransitionClaimInput } from '../data/validators'
8
8
 
9
9
  const logger = createLogger('warranty_claims')
10
- const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000'
11
10
 
12
11
  export const metadata = {
13
12
  event: 'shipping_carriers.shipment.delivered',
@@ -182,12 +181,13 @@ export default async function handle(payload: unknown, ctx: SubscriberContext):
182
181
 
183
182
  const commandCtx: CommandRuntimeContext = {
184
183
  container: container as unknown as AwilixContainer,
185
- auth: {
186
- sub: SYSTEM_USER_ID,
184
+ auth: null,
185
+ organizationScope: {
186
+ selectedId: organizationId,
187
+ filterIds: null,
188
+ allowedIds: null,
187
189
  tenantId,
188
- orgId: organizationId,
189
190
  },
190
- organizationScope: null,
191
191
  selectedOrganizationId: organizationId,
192
192
  organizationIds: [organizationId],
193
193
  systemActor: true,
@@ -734,19 +734,37 @@ async function findReservationBucketBalance(
734
734
  })
735
735
  }
736
736
 
737
+ type BalanceBucketInput = {
738
+ warehouseId: string
739
+ locationId: string
740
+ catalogVariantId: string
741
+ lotId?: string
742
+ serialNumber?: string
743
+ }
744
+
745
+ const BALANCE_BUCKET_CREATE_LOCK_SQL = 'select pg_advisory_xact_lock(hashtextextended(?, 0))'
746
+
747
+ function balanceBucketCreateLockKey(input: BalanceBucketInput): string {
748
+ return [
749
+ 'wms:inventory-balance-bucket',
750
+ input.warehouseId.toLowerCase(),
751
+ input.locationId.toLowerCase(),
752
+ input.catalogVariantId.toLowerCase(),
753
+ input.lotId?.toLowerCase() ?? '',
754
+ input.serialNumber ?? '',
755
+ ].join(':')
756
+ }
757
+
737
758
  async function upsertBalanceBucket(
738
759
  em: EntityManager,
739
760
  scope: Scope,
740
- input: {
741
- warehouseId: string
742
- locationId: string
743
- catalogVariantId: string
744
- lotId?: string
745
- serialNumber?: string
746
- },
761
+ input: BalanceBucketInput,
747
762
  ): Promise<{ balance: InventoryBalance; created: boolean }> {
748
763
  const existing = await findExactBalanceForUpdate(em, scope, input)
749
764
  if (existing) return { balance: existing, created: false }
765
+ await em.execute(BALANCE_BUCKET_CREATE_LOCK_SQL, [balanceBucketCreateLockKey(input)])
766
+ const createdConcurrently = await findExactBalanceForUpdate(em, scope, input)
767
+ if (createdConcurrently) return { balance: createdConcurrently, created: false }
750
768
  const balance = em.create(InventoryBalance, {
751
769
  organizationId: scope.organizationId,
752
770
  tenantId: scope.tenantId,
@@ -1608,15 +1626,17 @@ const moveInventoryCommand: CommandHandler<InventoryMoveInput, { movementId: str
1608
1626
  if (sourceWarehouseId !== input.warehouseId || targetWarehouseId !== input.warehouseId) {
1609
1627
  throw new CrudHttpError(422, { error: 'invalid_location' })
1610
1628
  }
1611
- const sourceResult = await upsertBalanceBucket(trx, scope, {
1629
+ const sourceBalance = await findExactBalanceForUpdate(trx, scope, {
1612
1630
  warehouseId: input.warehouseId,
1613
1631
  locationId: input.fromLocationId,
1614
1632
  catalogVariantId: input.catalogVariantId,
1615
1633
  lotId: input.lotId,
1616
1634
  serialNumber: input.serialNumber,
1617
1635
  })
1618
- const sourceBalance = sourceResult.balance
1619
- const sourceBalanceAction = sourceResult.created ? ('created' as const) : ('updated' as const)
1636
+ if (!sourceBalance) {
1637
+ throw new CrudHttpError(409, { error: 'insufficient_stock' })
1638
+ }
1639
+ const sourceBalanceAction = 'updated' as const
1620
1640
  const targetResult = await upsertBalanceBucket(trx, scope, {
1621
1641
  warehouseId: input.warehouseId,
1622
1642
  locationId: input.toLocationId,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Async-activity wait resolution, PURE.
3
+ *
4
+ * A transition that queues async activities parks its token (instance or
5
+ * branch) and records the queue job ids it is waiting for in
6
+ * `_pendingAsyncActivities`. The worker logs one `ACTIVITY_COMPLETED` /
7
+ * `ACTIVITY_FAILED` event per delivery attempt, keyed by `jobId`, and the event
8
+ * log keeps every earlier wait's events. The wait is therefore decided by the
9
+ * outcome of exactly the jobs it queued — one outcome per job — and never by
10
+ * events of an earlier wait, an earlier attempt of the run, or a duplicate
11
+ * delivery. A job with a completed attempt counts as completed; otherwise any
12
+ * logged failed attempt counts as its failure, whether or not the queue retries
13
+ * it later. `events` must be in log order: the last event per job and outcome
14
+ * is the one used.
15
+ */
16
+
17
+ export const ASYNC_ACTIVITY_OUTCOME_EVENT_TYPES = ['ACTIVITY_COMPLETED', 'ACTIVITY_FAILED'] as const
18
+
19
+ export type AsyncActivityOutcomeEvent = {
20
+ eventType: string
21
+ eventData: Record<string, unknown> | null | undefined
22
+ }
23
+
24
+ export type AsyncActivityWaitResolution<TEvent extends AsyncActivityOutcomeEvent> = {
25
+ settled: boolean
26
+ completed: TEvent[]
27
+ failed: TEvent[]
28
+ }
29
+
30
+ export function readPendingAsyncJobIds(pending: unknown): string[] {
31
+ if (!Array.isArray(pending)) return []
32
+ const jobIds: string[] = []
33
+ for (const entry of pending) {
34
+ const jobId = entry && typeof entry === 'object' ? (entry as { jobId?: unknown }).jobId : undefined
35
+ if (typeof jobId === 'string' && jobId.length > 0 && !jobIds.includes(jobId)) jobIds.push(jobId)
36
+ }
37
+ return jobIds
38
+ }
39
+
40
+ export function resolveAsyncActivityWait<TEvent extends AsyncActivityOutcomeEvent>(
41
+ pendingJobIds: readonly string[],
42
+ events: readonly TEvent[],
43
+ ): AsyncActivityWaitResolution<TEvent> {
44
+ const completedByJob = new Map<string, TEvent>()
45
+ const failedByJob = new Map<string, TEvent>()
46
+ for (const event of events) {
47
+ const jobId = event.eventData?.jobId
48
+ if (typeof jobId !== 'string') continue
49
+ if (event.eventType === 'ACTIVITY_COMPLETED') completedByJob.set(jobId, event)
50
+ else if (event.eventType === 'ACTIVITY_FAILED') failedByJob.set(jobId, event)
51
+ }
52
+
53
+ const completed: TEvent[] = []
54
+ const failed: TEvent[] = []
55
+ let settled = true
56
+ for (const jobId of pendingJobIds) {
57
+ const completion = completedByJob.get(jobId)
58
+ if (completion) {
59
+ completed.push(completion)
60
+ continue
61
+ }
62
+ const failure = failedByJob.get(jobId)
63
+ if (failure) {
64
+ failed.push(failure)
65
+ continue
66
+ }
67
+ settled = false
68
+ }
69
+
70
+ return { settled, completed, failed }
71
+ }
@@ -30,6 +30,11 @@ import {
30
30
  WorkflowEvent,
31
31
  } from '../data/entities'
32
32
  import { logWorkflowEvent } from './event-logger'
33
+ import {
34
+ ASYNC_ACTIVITY_OUTCOME_EVENT_TYPES,
35
+ readPendingAsyncJobIds,
36
+ resolveAsyncActivityWait,
37
+ } from './async-activity-wait'
33
38
  import * as stepHandler from './step-handler'
34
39
  import { branchToken, mergeTokenContext, type ExecutionToken } from './execution-token'
35
40
  import { WORKFLOW_ERROR_CONTEXT_KEY, buildErrorContextEntry } from './error-routing'
@@ -202,26 +207,30 @@ export async function resumeBranchAfterActivities(
202
207
  }
203
208
 
204
209
  const namespace = branch.contextNamespace || {}
205
- const pendingJobIds = (namespace._pendingAsyncActivities as any[]) || []
206
-
207
- const completedEvents = await em.find(WorkflowEvent, {
208
- workflowInstanceId: instanceId,
209
- branchInstanceId,
210
- eventType: 'ACTIVITY_COMPLETED',
211
- eventData: { async: true },
212
- })
213
- const failedCount = await em.count(WorkflowEvent, {
214
- workflowInstanceId: instanceId,
215
- branchInstanceId,
216
- eventType: 'ACTIVITY_FAILED',
217
- eventData: { async: true },
218
- })
210
+ const pendingJobIds = readPendingAsyncJobIds(namespace._pendingAsyncActivities)
211
+
212
+ const outcomeEvents = pendingJobIds.length === 0
213
+ ? []
214
+ : await em.find(
215
+ WorkflowEvent,
216
+ {
217
+ workflowInstanceId: instanceId,
218
+ branchInstanceId,
219
+ eventType: { $in: [...ASYNC_ACTIVITY_OUTCOME_EVENT_TYPES] },
220
+ eventData: { async: true, jobId: { $in: pendingJobIds } },
221
+ },
222
+ { orderBy: { occurredAt: 'asc', id: 'asc' } },
223
+ )
224
+ const wait = resolveAsyncActivityWait(pendingJobIds, outcomeEvents)
219
225
 
220
- if (completedEvents.length + failedCount < pendingJobIds.length) {
226
+ if (!wait.settled) {
221
227
  // Still waiting on other branch activities.
222
228
  return { continueExecution: false }
223
229
  }
224
230
 
231
+ const completedEvents = wait.completed
232
+ const failedCount = wait.failed.length
233
+
225
234
  const now = new Date()
226
235
  if (failedCount > 0) {
227
236
  branch.status = 'FAILED'
@@ -22,6 +22,11 @@ import {
22
22
  type WorkflowInstanceErrorHandlerMetadata,
23
23
  } from '../data/entities'
24
24
  import { compensateWorkflow } from './compensation-handler'
25
+ import {
26
+ ASYNC_ACTIVITY_OUTCOME_EVENT_TYPES,
27
+ readPendingAsyncJobIds,
28
+ resolveAsyncActivityWait,
29
+ } from './async-activity-wait'
25
30
  import { WORKFLOW_ENGINE_VERSION, isEngineVersionSupported } from './engine-version'
26
31
  import {
27
32
  WORKFLOW_ERROR_CONTEXT_KEY,
@@ -1635,33 +1640,31 @@ export async function resumeWorkflowAfterActivities(
1635
1640
  throw new Error('Workflow instance not waiting for activities')
1636
1641
  }
1637
1642
 
1638
- const pendingJobIds = (instance.context._pendingAsyncActivities as any[]) || []
1643
+ const pendingJobIds = readPendingAsyncJobIds(instance.context._pendingAsyncActivities)
1639
1644
 
1640
- const completedActivities = await trx.count(WorkflowEvent, {
1641
- workflowInstanceId: instanceId,
1642
- eventType: 'ACTIVITY_COMPLETED',
1643
- eventData: { async: true },
1644
- })
1645
-
1646
- const failedActivities = await trx.count(WorkflowEvent, {
1647
- workflowInstanceId: instanceId,
1648
- eventType: 'ACTIVITY_FAILED',
1649
- eventData: { async: true },
1650
- })
1651
-
1652
- const totalProcessed = completedActivities + failedActivities
1645
+ const outcomeEvents = pendingJobIds.length === 0
1646
+ ? []
1647
+ : await trx.find(
1648
+ WorkflowEvent,
1649
+ {
1650
+ workflowInstanceId: instanceId,
1651
+ eventType: { $in: [...ASYNC_ACTIVITY_OUTCOME_EVENT_TYPES] },
1652
+ eventData: { async: true, jobId: { $in: pendingJobIds } },
1653
+ },
1654
+ { orderBy: { occurredAt: 'asc', id: 'asc' } },
1655
+ )
1656
+ const wait = resolveAsyncActivityWait(pendingJobIds, outcomeEvents)
1653
1657
 
1654
- if (totalProcessed < pendingJobIds.length) {
1658
+ if (!wait.settled) {
1655
1659
  throw new Error('Activities still pending')
1656
1660
  }
1657
1661
 
1658
- if (failedActivities > 0) {
1659
- const failedEvents = await trx.find(WorkflowEvent, {
1660
- workflowInstanceId: instanceId,
1661
- eventType: 'ACTIVITY_FAILED',
1662
- eventData: { async: true },
1663
- })
1662
+ const completedEvents = wait.completed
1663
+ const failedEvents = wait.failed
1664
+ const completedActivities = completedEvents.length
1665
+ const failedActivities = failedEvents.length
1664
1666
 
1667
+ if (failedActivities > 0) {
1665
1668
  instance.status = 'FAILED'
1666
1669
  instance.errorMessage = `${failedActivities} async activities failed`
1667
1670
  instance.errorDetails = {
@@ -1688,12 +1691,6 @@ export async function resumeWorkflowAfterActivities(
1688
1691
  return { continueExecution: false }
1689
1692
  }
1690
1693
 
1691
- const completedEvents = await trx.find(WorkflowEvent, {
1692
- workflowInstanceId: instanceId,
1693
- eventType: 'ACTIVITY_COMPLETED',
1694
- eventData: { async: true },
1695
- })
1696
-
1697
1694
  for (const event of completedEvents) {
1698
1695
  if (event.eventData.output) {
1699
1696
  instance.context = {