@open-mercato/core 0.4.5-develop-811deeb983 → 0.4.5-develop-3d8e759e45
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/dist/modules/catalog/inbox-actions.js +51 -0
- package/dist/modules/catalog/inbox-actions.js.map +7 -0
- package/dist/modules/customers/inbox-actions.js +230 -0
- package/dist/modules/customers/inbox-actions.js.map +7 -0
- package/dist/modules/inbox_ops/api/emails/[id]/route.js +40 -1
- package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/extract/route.js +87 -0
- package/dist/modules/inbox_ops/api/extract/route.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js +6 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/translate/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +40 -14
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/log/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js +161 -79
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js +109 -62
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.js.map +3 -3
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +36 -14
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.meta.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/ActionCard.js +65 -10
- package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +58 -10
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/inbox_ops/lib/constants.js.map +2 -2
- package/dist/modules/inbox_ops/lib/contactValidation.js +40 -0
- package/dist/modules/inbox_ops/lib/contactValidation.js.map +7 -0
- package/dist/modules/inbox_ops/lib/executionEngine.js +31 -826
- package/dist/modules/inbox_ops/lib/executionEngine.js.map +3 -3
- package/dist/modules/inbox_ops/lib/executionHelpers.js +368 -0
- package/dist/modules/inbox_ops/lib/executionHelpers.js.map +7 -0
- package/dist/modules/inbox_ops/lib/extractionPrompt.js +28 -35
- package/dist/modules/inbox_ops/lib/extractionPrompt.js.map +3 -3
- package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js +1 -0
- package/dist/modules/inbox_ops/lib/inbox-actions-generated.d.js.map +7 -0
- package/dist/modules/inbox_ops/lib/translationProvider.js +15 -10
- package/dist/modules/inbox_ops/lib/translationProvider.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js +16 -16
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +2 -2
- package/dist/modules/sales/inbox-actions.js +278 -0
- package/dist/modules/sales/inbox-actions.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/jest.mocks/inbox-actions.generated.js +5 -0
- package/package.json +2 -2
- package/src/modules/catalog/inbox-actions.ts +60 -0
- package/src/modules/customers/inbox-actions.ts +285 -0
- package/src/modules/inbox_ops/api/emails/[id]/route.ts +44 -0
- package/src/modules/inbox_ops/api/extract/route.ts +94 -0
- package/src/modules/inbox_ops/api/proposals/[id]/translate/route.ts +6 -1
- package/src/modules/inbox_ops/api/proposals/counts/route.ts +2 -0
- package/src/modules/inbox_ops/backend/inbox-ops/log/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +43 -13
- package/src/modules/inbox_ops/backend/inbox-ops/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +176 -81
- package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/proposals/[id]/page.tsx +122 -68
- package/src/modules/inbox_ops/backend/inbox-ops/settings/page.meta.ts +2 -2
- package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +36 -14
- package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +91 -7
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +64 -12
- package/src/modules/inbox_ops/lib/constants.ts +9 -0
- package/src/modules/inbox_ops/lib/contactValidation.ts +54 -0
- package/src/modules/inbox_ops/lib/executionEngine.ts +47 -1060
- package/src/modules/inbox_ops/lib/executionHelpers.ts +527 -0
- package/src/modules/inbox_ops/lib/extractionPrompt.ts +45 -34
- package/src/modules/inbox_ops/lib/inbox-actions-generated.d.ts +11 -0
- package/src/modules/inbox_ops/lib/translationProvider.ts +16 -10
- package/src/modules/inbox_ops/subscribers/extractionWorker.ts +16 -18
- package/src/modules/sales/inbox-actions.ts +359 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { generateText } from 'ai'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import {
|
|
4
4
|
resolveOpenCodeModel,
|
|
@@ -8,7 +8,7 @@ import { createStructuredModel, resolveExtractionProviderId, withTimeout } from
|
|
|
8
8
|
|
|
9
9
|
const LANGUAGE_NAMES: Record<string, string> = { en: 'English', de: 'German', es: 'Spanish', pl: 'Polish' }
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const translationResultSchema = z.object({
|
|
12
12
|
summary: z.string(),
|
|
13
13
|
actions: z.record(z.string(), z.string()),
|
|
14
14
|
})
|
|
@@ -35,20 +35,26 @@ export async function translateProposalContent(input: {
|
|
|
35
35
|
|
|
36
36
|
const timeoutMs = parseInt(process.env.INBOX_OPS_TRANSLATION_TIMEOUT_MS || '30000', 10)
|
|
37
37
|
|
|
38
|
+
const actionIds = Object.keys(input.actionDescriptions)
|
|
39
|
+
|
|
38
40
|
const result = await withTimeout(
|
|
39
|
-
|
|
41
|
+
generateText({
|
|
40
42
|
model,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
system: `You are a professional translator. Translate the provided content from ${sourceLang} to ${targetLang}. Preserve proper nouns, numbers, dates, currencies, product names, and company names exactly as they appear. Maintain the same tone and meaning. Respond ONLY with valid JSON, no markdown fences.`,
|
|
44
|
+
prompt: `Translate and return JSON with this exact shape:
|
|
45
|
+
{"summary": "translated summary", "actions": {"action-id-1": "translated description", ...}}
|
|
46
|
+
|
|
47
|
+
Content to translate:
|
|
48
|
+
${JSON.stringify({ summary: input.summary, actions: input.actionDescriptions })}
|
|
49
|
+
|
|
50
|
+
Action IDs to preserve exactly: ${JSON.stringify(actionIds)}`,
|
|
47
51
|
temperature: 0,
|
|
48
52
|
}),
|
|
49
53
|
timeoutMs,
|
|
50
54
|
`Translation timed out after ${timeoutMs}ms`,
|
|
51
55
|
)
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
const text = result.text.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '').trim()
|
|
58
|
+
const parsed = translationResultSchema.parse(JSON.parse(text))
|
|
59
|
+
return parsed
|
|
54
60
|
}
|
|
@@ -160,7 +160,7 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
160
160
|
const maxTextSize = parseInt(process.env.INBOX_OPS_MAX_TEXT_SIZE || '204800', 10)
|
|
161
161
|
const truncatedText = fullText.slice(0, maxTextSize)
|
|
162
162
|
|
|
163
|
-
const systemPrompt = buildExtractionSystemPrompt(contactMatches, catalogProducts, undefined, workingLanguage)
|
|
163
|
+
const systemPrompt = await buildExtractionSystemPrompt(contactMatches, catalogProducts, undefined, workingLanguage)
|
|
164
164
|
const userPrompt = buildExtractionUserPrompt(truncatedText)
|
|
165
165
|
|
|
166
166
|
let extractionResult: ReturnType<typeof extractionOutputSchema.parse>
|
|
@@ -269,22 +269,20 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
269
269
|
|
|
270
270
|
action.payloadJson = JSON.stringify(enriched)
|
|
271
271
|
|
|
272
|
-
// Discrepancy descriptions are stored in the DB and rendered on the proposal review page.
|
|
273
|
-
// Not i18n keys — the proposal UI displays them as-is for operator guidance.
|
|
274
272
|
for (const warning of warnings) {
|
|
275
273
|
if (warning === 'no_channel_resolved') {
|
|
276
274
|
enrichmentDiscrepancies.push({
|
|
277
275
|
actionIndex,
|
|
278
276
|
type: 'other',
|
|
279
277
|
severity: 'error',
|
|
280
|
-
description: '
|
|
278
|
+
description: 'inbox_ops.discrepancy.desc.no_channel',
|
|
281
279
|
})
|
|
282
280
|
} else if (warning === 'no_currency_resolved') {
|
|
283
281
|
enrichmentDiscrepancies.push({
|
|
284
282
|
actionIndex,
|
|
285
283
|
type: 'currency_mismatch',
|
|
286
284
|
severity: 'warning',
|
|
287
|
-
description: '
|
|
285
|
+
description: 'inbox_ops.discrepancy.desc.no_currency',
|
|
288
286
|
})
|
|
289
287
|
}
|
|
290
288
|
}
|
|
@@ -317,7 +315,7 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
317
315
|
actionIndex,
|
|
318
316
|
type: 'product_not_found',
|
|
319
317
|
severity: 'error',
|
|
320
|
-
description:
|
|
318
|
+
description: 'inbox_ops.discrepancy.desc.product_not_matched',
|
|
321
319
|
foundValue: productName,
|
|
322
320
|
})
|
|
323
321
|
const nameKey = productName.toLowerCase().trim()
|
|
@@ -328,7 +326,7 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
328
326
|
const currencyCode = typeof parsedPayload.currencyCode === 'string' ? parsedPayload.currencyCode : undefined
|
|
329
327
|
autoProductActions.push({
|
|
330
328
|
actionType: 'create_product',
|
|
331
|
-
description:
|
|
329
|
+
description: 'inbox_ops.action.desc.create_product',
|
|
332
330
|
confidence: 0.9,
|
|
333
331
|
requiredFeature: REQUIRED_FEATURES_MAP.create_product,
|
|
334
332
|
payloadJson: JSON.stringify({
|
|
@@ -412,7 +410,7 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
412
410
|
proposalId: proposalId,
|
|
413
411
|
sortOrder: combinedProposedActions.length + index,
|
|
414
412
|
actionType: 'draft_reply',
|
|
415
|
-
description:
|
|
413
|
+
description: 'inbox_ops.action.desc.draft_reply',
|
|
416
414
|
payload: {
|
|
417
415
|
to: reply.to,
|
|
418
416
|
toName: reply.toName,
|
|
@@ -468,8 +466,8 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
468
466
|
createDiscrepancy(em, proposalId, allActions, {
|
|
469
467
|
type: 'unknown_contact',
|
|
470
468
|
severity: 'warning',
|
|
471
|
-
description:
|
|
472
|
-
foundValue: match.participant.email
|
|
469
|
+
description: 'inbox_ops.discrepancy.desc.no_matching_contact',
|
|
470
|
+
foundValue: `${match.participant.name} (${match.participant.email})`,
|
|
473
471
|
}, scope),
|
|
474
472
|
)
|
|
475
473
|
}
|
|
@@ -483,8 +481,8 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
483
481
|
createDiscrepancy(em, proposalId, allActions, {
|
|
484
482
|
type: 'unknown_contact',
|
|
485
483
|
severity: 'warning',
|
|
486
|
-
description:
|
|
487
|
-
foundValue: participant.email
|
|
484
|
+
description: 'inbox_ops.discrepancy.desc.no_matching_contact',
|
|
485
|
+
foundValue: `${participant.name} (${participant.email})`,
|
|
488
486
|
}, scope),
|
|
489
487
|
)
|
|
490
488
|
}
|
|
@@ -505,7 +503,7 @@ export default async function handle(payload: EmailReceivedPayload, ctx: Resolve
|
|
|
505
503
|
actionIndex,
|
|
506
504
|
type: 'unknown_contact',
|
|
507
505
|
severity: 'error',
|
|
508
|
-
description:
|
|
506
|
+
description: 'inbox_ops.discrepancy.desc.draft_reply_no_contact',
|
|
509
507
|
foundValue: toEmail,
|
|
510
508
|
}, scope),
|
|
511
509
|
)
|
|
@@ -606,7 +604,7 @@ function buildContactActionsForUnmatchedParticipants(
|
|
|
606
604
|
})
|
|
607
605
|
.map((m) => ({
|
|
608
606
|
actionType: 'create_contact' as const,
|
|
609
|
-
description:
|
|
607
|
+
description: 'inbox_ops.action.desc.create_contact',
|
|
610
608
|
confidence: 0.9,
|
|
611
609
|
requiredFeature: REQUIRED_FEATURES_MAP.create_contact,
|
|
612
610
|
payloadJson: JSON.stringify({
|
|
@@ -647,7 +645,7 @@ function buildLinkContactActionsForMatchedParticipants(
|
|
|
647
645
|
})
|
|
648
646
|
.map((m) => ({
|
|
649
647
|
actionType: 'link_contact' as const,
|
|
650
|
-
description:
|
|
648
|
+
description: 'inbox_ops.action.desc.link_contact',
|
|
651
649
|
confidence: 0.95,
|
|
652
650
|
requiredFeature: REQUIRED_FEATURES_MAP.link_contact,
|
|
653
651
|
payloadJson: JSON.stringify({
|
|
@@ -701,7 +699,7 @@ function buildContactActionsForUnmatchedLlmParticipants(
|
|
|
701
699
|
})
|
|
702
700
|
.map((p) => ({
|
|
703
701
|
actionType: 'create_contact' as const,
|
|
704
|
-
description:
|
|
702
|
+
description: 'inbox_ops.action.desc.create_contact',
|
|
705
703
|
confidence: 0.85,
|
|
706
704
|
requiredFeature: REQUIRED_FEATURES_MAP.create_contact,
|
|
707
705
|
payloadJson: JSON.stringify({
|
|
@@ -749,8 +747,8 @@ async function detectDuplicateOrders(
|
|
|
749
747
|
discrepancies.push({
|
|
750
748
|
type: 'duplicate_order',
|
|
751
749
|
severity: 'error',
|
|
752
|
-
description:
|
|
753
|
-
expectedValue:
|
|
750
|
+
description: 'inbox_ops.discrepancy.desc.duplicate_order_reference',
|
|
751
|
+
expectedValue: existing.orderNumber || existing.id,
|
|
754
752
|
foundValue: customerReference,
|
|
755
753
|
actionIndex: action.index,
|
|
756
754
|
})
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { InboxActionDefinition, InboxActionExecutionContext } from '@open-mercato/shared/modules/inbox-actions'
|
|
2
|
+
import { orderPayloadSchema, updateOrderPayloadSchema, updateShipmentPayloadSchema } from '../inbox_ops/data/validators'
|
|
3
|
+
import type { OrderPayload, UpdateOrderPayload, UpdateShipmentPayload } from '../inbox_ops/data/validators'
|
|
4
|
+
import {
|
|
5
|
+
asHelperContext,
|
|
6
|
+
ExecutionError,
|
|
7
|
+
executeCommand,
|
|
8
|
+
buildSourceMetadata,
|
|
9
|
+
resolveOrderByReference,
|
|
10
|
+
resolveFirstChannelId,
|
|
11
|
+
resolveChannelCurrency,
|
|
12
|
+
resolveEffectiveDocumentKind,
|
|
13
|
+
resolveShipmentStatusEntryId,
|
|
14
|
+
resolveCustomerEntityIdByEmail,
|
|
15
|
+
resolveEntityClass,
|
|
16
|
+
normalizeAddressSnapshot,
|
|
17
|
+
parseDateToken,
|
|
18
|
+
parseNumberToken,
|
|
19
|
+
loadOrderLineItems,
|
|
20
|
+
matchLineItemByName,
|
|
21
|
+
} from '../inbox_ops/lib/executionHelpers'
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// create_order
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
async function executeCreateDocumentAction(
|
|
28
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
29
|
+
ctx: InboxActionExecutionContext,
|
|
30
|
+
kind: 'order' | 'quote',
|
|
31
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null }> {
|
|
32
|
+
const hCtx = asHelperContext(ctx)
|
|
33
|
+
const payload = action.payload as OrderPayload
|
|
34
|
+
|
|
35
|
+
let resolvedChannelId: string | undefined = payload.channelId
|
|
36
|
+
if (!resolvedChannelId) {
|
|
37
|
+
resolvedChannelId = (await resolveFirstChannelId(hCtx)) ?? undefined
|
|
38
|
+
if (!resolvedChannelId) {
|
|
39
|
+
throw new ExecutionError('No sales channel available. Create a channel first or set channelId in the payload.', 400)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const currencyCode = payload.currencyCode.trim().toUpperCase()
|
|
44
|
+
const lines = payload.lineItems.map((line, index) => {
|
|
45
|
+
const quantity = parseNumberToken(line.quantity, `lineItems[${index}].quantity`)
|
|
46
|
+
const unitPrice = line.unitPrice
|
|
47
|
+
? parseNumberToken(line.unitPrice, `lineItems[${index}].unitPrice`)
|
|
48
|
+
: undefined
|
|
49
|
+
|
|
50
|
+
const mappedLine: Record<string, unknown> = {
|
|
51
|
+
lineNumber: index + 1,
|
|
52
|
+
kind: line.kind ?? (line.productId ? 'product' : 'service'),
|
|
53
|
+
name: line.productName,
|
|
54
|
+
description: line.description,
|
|
55
|
+
quantity,
|
|
56
|
+
currencyCode,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (line.productId) mappedLine.productId = line.productId
|
|
60
|
+
if (line.variantId) mappedLine.productVariantId = line.variantId
|
|
61
|
+
if (unitPrice !== undefined) mappedLine.unitPriceNet = unitPrice
|
|
62
|
+
if (line.sku || line.catalogPrice) {
|
|
63
|
+
mappedLine.catalogSnapshot = {
|
|
64
|
+
sku: line.sku ?? null,
|
|
65
|
+
catalogPrice: line.catalogPrice ?? null,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return mappedLine
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const metadata = buildSourceMetadata(action.id, action.proposalId)
|
|
73
|
+
|
|
74
|
+
let resolvedCustomerEntityId = payload.customerEntityId
|
|
75
|
+
if (!resolvedCustomerEntityId && payload.customerEmail) {
|
|
76
|
+
resolvedCustomerEntityId = (await resolveCustomerEntityIdByEmail(hCtx, payload.customerEmail)) ?? undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const createInput: Record<string, unknown> = {
|
|
80
|
+
organizationId: hCtx.organizationId,
|
|
81
|
+
tenantId: hCtx.tenantId,
|
|
82
|
+
customerEntityId: resolvedCustomerEntityId,
|
|
83
|
+
customerReference: payload.customerReference,
|
|
84
|
+
channelId: resolvedChannelId,
|
|
85
|
+
currencyCode,
|
|
86
|
+
taxRateId: payload.taxRateId,
|
|
87
|
+
comments: payload.notes,
|
|
88
|
+
metadata,
|
|
89
|
+
lines,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!resolvedCustomerEntityId) {
|
|
93
|
+
createInput.customerSnapshot = {
|
|
94
|
+
displayName: payload.customerName,
|
|
95
|
+
...(payload.customerEmail && { primaryEmail: payload.customerEmail }),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const normalizedBilling = payload.billingAddress
|
|
100
|
+
? normalizeAddressSnapshot(payload.billingAddress)
|
|
101
|
+
: undefined
|
|
102
|
+
const normalizedShipping = payload.shippingAddress
|
|
103
|
+
? normalizeAddressSnapshot(payload.shippingAddress)
|
|
104
|
+
: undefined
|
|
105
|
+
|
|
106
|
+
if (normalizedShipping || normalizedBilling) {
|
|
107
|
+
createInput.shippingAddressSnapshot = normalizedShipping ?? normalizedBilling
|
|
108
|
+
createInput.billingAddressSnapshot = normalizedBilling ?? normalizedShipping
|
|
109
|
+
} else if (payload.billingAddressId || payload.shippingAddressId) {
|
|
110
|
+
createInput.billingAddressId = payload.billingAddressId ?? payload.shippingAddressId
|
|
111
|
+
createInput.shippingAddressId = payload.shippingAddressId ?? payload.billingAddressId
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const requestedDeliveryAt = parseDateToken(payload.requestedDeliveryDate ?? undefined)
|
|
115
|
+
if (requestedDeliveryAt) {
|
|
116
|
+
createInput.expectedDeliveryAt = requestedDeliveryAt
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const effectiveKind = kind === 'order'
|
|
120
|
+
? await resolveEffectiveDocumentKind(hCtx, resolvedChannelId)
|
|
121
|
+
: kind
|
|
122
|
+
|
|
123
|
+
if (effectiveKind === 'order') {
|
|
124
|
+
const result = await executeCommand<Record<string, unknown>, { orderId?: string }>(
|
|
125
|
+
hCtx,
|
|
126
|
+
'sales.orders.create',
|
|
127
|
+
createInput,
|
|
128
|
+
)
|
|
129
|
+
if (!result.orderId) {
|
|
130
|
+
throw new ExecutionError('Order creation did not return an order ID', 500)
|
|
131
|
+
}
|
|
132
|
+
return { createdEntityId: result.orderId, createdEntityType: 'sales_order' }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await executeCommand<Record<string, unknown>, { quoteId?: string }>(
|
|
136
|
+
hCtx,
|
|
137
|
+
'sales.quotes.create',
|
|
138
|
+
createInput,
|
|
139
|
+
)
|
|
140
|
+
if (!result.quoteId) {
|
|
141
|
+
throw new ExecutionError('Quote creation did not return a quote ID', 500)
|
|
142
|
+
}
|
|
143
|
+
return { createdEntityId: result.quoteId, createdEntityType: 'sales_quote' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// update_order
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async function executeUpdateOrderAction(
|
|
151
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
152
|
+
ctx: InboxActionExecutionContext,
|
|
153
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null }> {
|
|
154
|
+
const hCtx = asHelperContext(ctx)
|
|
155
|
+
const payload = action.payload as UpdateOrderPayload
|
|
156
|
+
|
|
157
|
+
const order = await resolveOrderByReference(hCtx, payload.orderId, payload.orderNumber)
|
|
158
|
+
|
|
159
|
+
const updateInput: Record<string, unknown> = {
|
|
160
|
+
id: order.id,
|
|
161
|
+
organizationId: hCtx.organizationId,
|
|
162
|
+
tenantId: hCtx.tenantId,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const newDeliveryDate = parseDateToken(payload.deliveryDateChange?.newDate)
|
|
166
|
+
if (newDeliveryDate) {
|
|
167
|
+
updateInput.expectedDeliveryAt = newDeliveryDate
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const noteLines = payload.noteAdditions?.map((note) => note.trim()).filter((note) => note.length > 0) ?? []
|
|
171
|
+
if (noteLines.length > 0) {
|
|
172
|
+
const mergedNotes = [order.comments ?? null, ...noteLines].filter(Boolean).join('\n')
|
|
173
|
+
updateInput.comments = mergedNotes
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (Object.keys(updateInput).length > 3) {
|
|
177
|
+
await executeCommand<Record<string, unknown>, { orderId?: string }>(
|
|
178
|
+
hCtx,
|
|
179
|
+
'sales.orders.update',
|
|
180
|
+
updateInput,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const quantityChanges = payload.quantityChanges ?? []
|
|
185
|
+
const orderLines = quantityChanges.length > 0 && quantityChanges.some((qc) => !qc.lineItemId)
|
|
186
|
+
? await loadOrderLineItems(hCtx, order.id)
|
|
187
|
+
: []
|
|
188
|
+
|
|
189
|
+
for (const quantityChange of quantityChanges) {
|
|
190
|
+
let lineItemId = quantityChange.lineItemId
|
|
191
|
+
if (!lineItemId) {
|
|
192
|
+
const matched = matchLineItemByName(orderLines, quantityChange.lineItemName)
|
|
193
|
+
if (matched) {
|
|
194
|
+
lineItemId = matched
|
|
195
|
+
} else {
|
|
196
|
+
const availableNames = orderLines.map((l) => l.name).filter(Boolean).join(', ')
|
|
197
|
+
throw new ExecutionError(
|
|
198
|
+
`Cannot resolve line item "${quantityChange.lineItemName}". Available line items: ${availableNames || 'none'}`,
|
|
199
|
+
400,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await executeCommand<{ body: Record<string, unknown> }, { orderId?: string; lineId?: string }>(
|
|
205
|
+
hCtx,
|
|
206
|
+
'sales.orders.lines.upsert',
|
|
207
|
+
{
|
|
208
|
+
body: {
|
|
209
|
+
id: lineItemId,
|
|
210
|
+
orderId: order.id,
|
|
211
|
+
organizationId: hCtx.organizationId,
|
|
212
|
+
tenantId: hCtx.tenantId,
|
|
213
|
+
quantity: parseNumberToken(quantityChange.newQuantity, 'quantityChanges.newQuantity'),
|
|
214
|
+
currencyCode: order.currencyCode,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { createdEntityId: order.id, createdEntityType: 'sales_order' }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// update_shipment
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async function executeUpdateShipmentAction(
|
|
228
|
+
action: { id: string; proposalId: string; payload: unknown },
|
|
229
|
+
ctx: InboxActionExecutionContext,
|
|
230
|
+
): Promise<{ createdEntityId?: string | null; createdEntityType?: string | null }> {
|
|
231
|
+
const hCtx = asHelperContext(ctx)
|
|
232
|
+
const payload = action.payload as UpdateShipmentPayload
|
|
233
|
+
|
|
234
|
+
const order = await resolveOrderByReference(hCtx, payload.orderId, payload.orderNumber)
|
|
235
|
+
|
|
236
|
+
const SalesShipmentClass = resolveEntityClass(hCtx, 'SalesShipment')
|
|
237
|
+
if (!SalesShipmentClass) {
|
|
238
|
+
throw new ExecutionError('Sales module entities not available', 503)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { findOneWithDecryption } = await import('@open-mercato/shared/lib/encryption/find')
|
|
242
|
+
|
|
243
|
+
const shipment = await findOneWithDecryption(
|
|
244
|
+
hCtx.em,
|
|
245
|
+
SalesShipmentClass,
|
|
246
|
+
{
|
|
247
|
+
order: order.id,
|
|
248
|
+
tenantId: hCtx.tenantId,
|
|
249
|
+
organizationId: hCtx.organizationId,
|
|
250
|
+
deletedAt: null,
|
|
251
|
+
},
|
|
252
|
+
{ orderBy: { createdAt: 'DESC' } },
|
|
253
|
+
{ tenantId: hCtx.tenantId, organizationId: hCtx.organizationId },
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if (!shipment) {
|
|
257
|
+
throw new ExecutionError('No shipment found for the referenced order', 404)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const statusEntryId = await resolveShipmentStatusEntryId(hCtx, payload.statusLabel)
|
|
261
|
+
if (!statusEntryId) {
|
|
262
|
+
throw new ExecutionError(`Shipment status "${payload.statusLabel}" not found`, 400)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const updateInput: Record<string, unknown> = {
|
|
266
|
+
id: shipment.id,
|
|
267
|
+
orderId: order.id,
|
|
268
|
+
organizationId: hCtx.organizationId,
|
|
269
|
+
tenantId: hCtx.tenantId,
|
|
270
|
+
statusEntryId,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (payload.trackingNumbers) updateInput.trackingNumbers = payload.trackingNumbers
|
|
274
|
+
if (payload.carrierName) updateInput.carrierName = payload.carrierName
|
|
275
|
+
if (payload.notes) updateInput.notes = payload.notes
|
|
276
|
+
|
|
277
|
+
const shippedAt = parseDateToken(payload.shippedAt)
|
|
278
|
+
const deliveredAt = parseDateToken(payload.deliveredAt)
|
|
279
|
+
if (shippedAt) updateInput.shippedAt = shippedAt
|
|
280
|
+
if (deliveredAt) updateInput.deliveredAt = deliveredAt
|
|
281
|
+
|
|
282
|
+
await executeCommand<Record<string, unknown>, { shipmentId?: string }>(
|
|
283
|
+
hCtx,
|
|
284
|
+
'sales.shipments.update',
|
|
285
|
+
updateInput,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return { createdEntityId: shipment.id, createdEntityType: 'sales_shipment' }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Normalization helper for order/quote payloads
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
async function normalizeOrderPayload(
|
|
296
|
+
payload: Record<string, unknown>,
|
|
297
|
+
ctx: InboxActionExecutionContext,
|
|
298
|
+
): Promise<Record<string, unknown>> {
|
|
299
|
+
if (!payload.currencyCode) {
|
|
300
|
+
const hCtx = asHelperContext(ctx)
|
|
301
|
+
const channelId = typeof payload.channelId === 'string' ? payload.channelId : null
|
|
302
|
+
const resolved = await resolveChannelCurrency(hCtx, channelId)
|
|
303
|
+
if (resolved) payload.currencyCode = resolved
|
|
304
|
+
}
|
|
305
|
+
return payload
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Exported action definitions
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
export const inboxActions: InboxActionDefinition[] = [
|
|
313
|
+
{
|
|
314
|
+
type: 'create_order',
|
|
315
|
+
requiredFeature: 'sales.orders.manage',
|
|
316
|
+
payloadSchema: orderPayloadSchema,
|
|
317
|
+
label: 'Create Sales Order',
|
|
318
|
+
promptSchema: `create_order / create_quote payload:
|
|
319
|
+
{ customerName: string, customerEmail?: string, customerEntityId?: uuid, channelId?: uuid, currencyCode: string (3-letter ISO), taxRateId?: uuid, lineItems: [{ productName: string (REQUIRED), productId?: uuid, variantId?: uuid, sku?: string, quantity: string, unitPrice?: string, kind?: "product"|"service", description?: string }], requestedDeliveryDate?: string, notes?: string, customerReference?: string (customer's own PO number or reference code — only set if explicitly stated in the email, do NOT use the email subject), shippingAddress?: { line1?: string, line2?: string, city?: string, state?: string, postalCode?: string, country?: string, company?: string, contactName?: string }, billingAddress?: { line1?: string, line2?: string, city?: string, state?: string, postalCode?: string, country?: string, company?: string, contactName?: string } }`,
|
|
320
|
+
promptRules: [
|
|
321
|
+
'ALWAYS propose a create_order or create_quote action when the customer expresses interest in buying, even if some product names are uncertain or not in the catalog. Use the best product name available; the system will flag unmatched products as discrepancies. Do NOT replace an order with a draft_reply asking for clarification — propose both if needed.',
|
|
322
|
+
'Use create_order when the customer has clearly confirmed they want to proceed (e.g., "let\'s go ahead", "please process", "confirmed"). Use create_quote when the customer is still inquiring, requesting pricing, asking for a proposal, or negotiating (e.g., "could you send a quote", "what would it cost", "we\'re interested in", "can you offer"). When in doubt, prefer create_quote.',
|
|
323
|
+
'For create_order / create_quote: each line item MUST have "productName" (the product name goes here, NOT in "description"). Include currencyCode and customerName.',
|
|
324
|
+
'For create_order / create_quote: extract shippingAddress and billingAddress as structured objects when addresses are mentioned. Parse street, city, postal code, country from the text. Do NOT put address data in notes.',
|
|
325
|
+
],
|
|
326
|
+
normalizePayload: normalizeOrderPayload,
|
|
327
|
+
execute: (action, ctx) => executeCreateDocumentAction(action, ctx, 'order'),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
type: 'create_quote',
|
|
331
|
+
requiredFeature: 'sales.quotes.manage',
|
|
332
|
+
payloadSchema: orderPayloadSchema,
|
|
333
|
+
label: 'Create Quote',
|
|
334
|
+
promptSchema: '(shared with create_order)',
|
|
335
|
+
normalizePayload: normalizeOrderPayload,
|
|
336
|
+
execute: (action, ctx) => executeCreateDocumentAction(action, ctx, 'quote'),
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
type: 'update_order',
|
|
340
|
+
requiredFeature: 'sales.orders.manage',
|
|
341
|
+
payloadSchema: updateOrderPayloadSchema,
|
|
342
|
+
label: 'Update Order',
|
|
343
|
+
promptSchema: `update_order payload:
|
|
344
|
+
{ orderId?: uuid, orderNumber?: string, quantityChanges?: [{ lineItemName: string, lineItemId?: uuid, oldQuantity?: string, newQuantity: string }], deliveryDateChange?: { oldDate?: string, newDate: string }, noteAdditions?: string[] }`,
|
|
345
|
+
execute: executeUpdateOrderAction,
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
type: 'update_shipment',
|
|
349
|
+
requiredFeature: 'sales.shipments.manage',
|
|
350
|
+
payloadSchema: updateShipmentPayloadSchema,
|
|
351
|
+
label: 'Update Shipment',
|
|
352
|
+
promptSchema: `update_shipment payload:
|
|
353
|
+
{ orderId?: uuid, orderNumber?: string, trackingNumbers?: string[], carrierName?: string, statusLabel: string, shippedAt?: string, deliveredAt?: string, estimatedDelivery?: string, notes?: string }`,
|
|
354
|
+
promptRules: ['For update_shipment: use statusLabel text only.'],
|
|
355
|
+
execute: executeUpdateShipmentAction,
|
|
356
|
+
},
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
export default inboxActions
|