@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/logout.js +6 -1
- package/dist/modules/auth/api/logout.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +6 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/catalog/ai-tools/mutation-pack.js +10 -5
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +2 -2
- package/dist/modules/catalog/ai-tools/types.js.map +2 -2
- package/dist/modules/customer_accounts/api/login.js +14 -7
- package/dist/modules/customer_accounts/api/login.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerUserService.js +10 -10
- package/dist/modules/customer_accounts/services/customerUserService.js.map +2 -2
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js +1 -1
- package/dist/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.js.map +1 -1
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js +12 -2
- package/dist/modules/forms/api/form-submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/form-submissions/route.js +16 -1
- package/dist/modules/forms/api/form-submissions/route.js.map +2 -2
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js +6 -1
- package/dist/modules/forms/api/public/submissions/[id]/submit/route.js.map +2 -2
- package/dist/modules/forms/api/runtime-helpers.js +16 -0
- package/dist/modules/forms/api/runtime-helpers.js.map +2 -2
- package/dist/modules/forms/data/validators.js.map +2 -2
- package/dist/modules/forms/lib/portal-subject-policy.js +17 -0
- package/dist/modules/forms/lib/portal-subject-policy.js.map +7 -0
- package/dist/modules/forms/lib/runtime-principal.js +27 -10
- package/dist/modules/forms/lib/runtime-principal.js.map +2 -2
- package/dist/modules/forms/services/distribution-service.js +35 -13
- package/dist/modules/forms/services/distribution-service.js.map +2 -2
- package/dist/modules/forms/services/submission-service.js +4 -0
- package/dist/modules/forms/services/submission-service.js.map +2 -2
- package/dist/modules/staff/commands/leave-requests.js +54 -24
- package/dist/modules/staff/commands/leave-requests.js.map +2 -2
- package/dist/modules/warranty_claims/api/portal/attachments/route.js +19 -7
- package/dist/modules/warranty_claims/api/portal/attachments/route.js.map +2 -2
- package/dist/modules/warranty_claims/commands/claims.js +1 -1
- package/dist/modules/warranty_claims/commands/claims.js.map +2 -2
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js +6 -6
- package/dist/modules/warranty_claims/subscribers/return-shipment-tracking.js.map +2 -2
- package/dist/modules/wms/commands/inventory-actions.js +19 -3
- package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
- package/dist/modules/workflows/lib/async-activity-wait.js +43 -0
- package/dist/modules/workflows/lib/async-activity-wait.js.map +7 -0
- package/dist/modules/workflows/lib/parallel-handler.js +20 -14
- package/dist/modules/workflows/lib/parallel-handler.js.map +2 -2
- package/dist/modules/workflows/lib/workflow-executor.js +21 -23
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/logout.ts +6 -1
- package/src/modules/auth/api/session/refresh.ts +6 -1
- package/src/modules/catalog/ai-tools/mutation-pack.ts +14 -5
- package/src/modules/catalog/ai-tools/types.ts +1 -0
- package/src/modules/customer_accounts/api/login.ts +15 -7
- package/src/modules/customer_accounts/services/customerUserService.ts +34 -10
- package/src/modules/forms/api/distributions/[distributionId]/invitations/[invitationId]/route.ts +1 -1
- package/src/modules/forms/api/form-submissions/[id]/submit/route.ts +16 -3
- package/src/modules/forms/api/form-submissions/route.ts +21 -2
- package/src/modules/forms/api/public/submissions/[id]/submit/route.ts +7 -0
- package/src/modules/forms/api/runtime-helpers.ts +16 -0
- package/src/modules/forms/data/validators.ts +2 -1
- package/src/modules/forms/i18n/de.json +1 -0
- package/src/modules/forms/i18n/en.json +1 -0
- package/src/modules/forms/i18n/es.json +1 -0
- package/src/modules/forms/i18n/ko.json +1 -0
- package/src/modules/forms/i18n/pl.json +1 -0
- package/src/modules/forms/lib/portal-subject-policy.ts +42 -0
- package/src/modules/forms/lib/runtime-principal.ts +35 -11
- package/src/modules/forms/services/distribution-service.ts +35 -13
- package/src/modules/forms/services/submission-service.ts +12 -0
- package/src/modules/staff/commands/leave-requests.ts +54 -23
- package/src/modules/staff/i18n/de.json +2 -0
- package/src/modules/staff/i18n/en.json +2 -0
- package/src/modules/staff/i18n/es.json +2 -0
- package/src/modules/staff/i18n/ko.json +2 -0
- package/src/modules/staff/i18n/pl.json +2 -0
- package/src/modules/warranty_claims/api/portal/attachments/route.ts +20 -8
- package/src/modules/warranty_claims/commands/claims.ts +1 -1
- package/src/modules/warranty_claims/subscribers/return-shipment-tracking.ts +5 -5
- package/src/modules/wms/commands/inventory-actions.ts +30 -10
- package/src/modules/workflows/lib/async-activity-wait.ts +71 -0
- package/src/modules/workflows/lib/parallel-handler.ts +24 -15
- 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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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 {
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1619
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 (
|
|
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
|
|
1643
|
+
const pendingJobIds = readPendingAsyncJobIds(instance.context._pendingAsyncActivities)
|
|
1639
1644
|
|
|
1640
|
-
const
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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 (
|
|
1658
|
+
if (!wait.settled) {
|
|
1655
1659
|
throw new Error('Activities still pending')
|
|
1656
1660
|
}
|
|
1657
1661
|
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
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 = {
|