@open-mercato/core 0.6.4-develop.4236.1.9fa6806b34 → 0.6.4-develop.4254.1.7a123d970c
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/helpers/integration/authFixtures.js +70 -1
- package/dist/helpers/integration/authFixtures.js.map +2 -2
- package/dist/helpers/integration/dbFixtures.js +98 -0
- package/dist/helpers/integration/dbFixtures.js.map +7 -0
- package/dist/modules/business_rules/api/execute/route.js +2 -1
- package/dist/modules/business_rules/api/execute/route.js.map +2 -2
- package/dist/modules/business_rules/api/rules/route.js +10 -0
- package/dist/modules/business_rules/api/rules/route.js.map +2 -2
- package/dist/modules/business_rules/backend/logs/[id]/page.js +24 -5
- package/dist/modules/business_rules/backend/logs/[id]/page.js.map +2 -2
- package/dist/modules/business_rules/cli.js +6 -0
- package/dist/modules/business_rules/cli.js.map +2 -2
- package/dist/modules/business_rules/lib/rule-engine.js +116 -9
- package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
- package/dist/modules/business_rules/subscribers/crud-rule-trigger.js +3 -2
- package/dist/modules/business_rules/subscribers/crud-rule-trigger.js.map +2 -2
- package/dist/modules/catalog/api/offers/route.js +15 -5
- package/dist/modules/catalog/api/offers/route.js.map +2 -2
- package/dist/modules/catalog/api/products/route.js +21 -4
- package/dist/modules/catalog/api/products/route.js.map +2 -2
- package/dist/modules/catalog/lib/pricing.js +6 -0
- package/dist/modules/catalog/lib/pricing.js.map +2 -2
- package/dist/modules/catalog/services/catalogPricingService.js +5 -1
- package/dist/modules/catalog/services/catalogPricingService.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js +19 -2
- package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js +27 -7
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/page.js +27 -7
- package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/page.js.map +2 -2
- package/dist/modules/customers/api/activities/route.js +15 -2
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/api/comments/route.js +15 -3
- package/dist/modules/customers/api/comments/route.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/people/route.js +2 -4
- package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +2 -4
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/companies/route.js +2 -4
- package/dist/modules/customers/api/deals/[id]/companies/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/people/route.js +2 -4
- package/dist/modules/customers/api/deals/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +2 -9
- package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/stats/route.js +2 -9
- package/dist/modules/customers/api/deals/[id]/stats/route.js.map +2 -2
- package/dist/modules/customers/api/entity-roles-factory.js +2 -8
- package/dist/modules/customers/api/entity-roles-factory.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/context.js +2 -4
- package/dist/modules/customers/api/people/[id]/companies/context.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +2 -4
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +2 -4
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/[id]/page.js +29 -8
- package/dist/modules/customers/backend/customers/people/[id]/page.js.map +2 -2
- package/dist/modules/directory/utils/organizationScopeGuard.js +22 -0
- package/dist/modules/directory/utils/organizationScopeGuard.js.map +7 -0
- package/dist/modules/progress/acl.js +8 -4
- package/dist/modules/progress/acl.js.map +2 -2
- package/dist/modules/workflows/backend/events/[id]/page.js +24 -6
- package/dist/modules/workflows/backend/events/[id]/page.js.map +2 -2
- package/dist/modules/workflows/backend/instances/[id]/page.js +27 -5
- package/dist/modules/workflows/backend/instances/[id]/page.js.map +2 -2
- package/dist/modules/workflows/backend/tasks/[id]/page.js +25 -6
- package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
- package/dist/modules/workflows/cli.js +8 -0
- package/dist/modules/workflows/cli.js.map +2 -2
- package/dist/modules/workflows/lib/seeds.js +8 -4
- package/dist/modules/workflows/lib/seeds.js.map +2 -2
- package/dist/modules/workflows/setup.js +3 -1
- package/dist/modules/workflows/setup.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/authFixtures.ts +98 -0
- package/src/helpers/integration/dbFixtures.ts +144 -0
- package/src/modules/business_rules/api/execute/route.ts +2 -1
- package/src/modules/business_rules/api/rules/route.ts +10 -0
- package/src/modules/business_rules/backend/logs/[id]/page.tsx +32 -7
- package/src/modules/business_rules/cli.ts +6 -0
- package/src/modules/business_rules/lib/rule-engine.ts +163 -9
- package/src/modules/business_rules/subscribers/crud-rule-trigger.ts +3 -2
- package/src/modules/catalog/api/offers/route.ts +20 -5
- package/src/modules/catalog/api/products/route.ts +23 -4
- package/src/modules/catalog/lib/pricing.ts +9 -0
- package/src/modules/catalog/services/catalogPricingService.ts +6 -0
- package/src/modules/currencies/backend/currencies/[id]/page.tsx +21 -2
- package/src/modules/currencies/i18n/de.json +1 -0
- package/src/modules/currencies/i18n/en.json +1 -0
- package/src/modules/currencies/i18n/es.json +1 -0
- package/src/modules/currencies/i18n/pl.json +1 -0
- package/src/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.tsx +34 -11
- package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/page.tsx +34 -11
- package/src/modules/customers/api/activities/route.ts +16 -5
- package/src/modules/customers/api/comments/route.ts +15 -5
- package/src/modules/customers/api/companies/[id]/people/route.ts +2 -4
- package/src/modules/customers/api/companies/[id]/route.ts +2 -5
- package/src/modules/customers/api/deals/[id]/companies/route.ts +2 -4
- package/src/modules/customers/api/deals/[id]/people/route.ts +2 -4
- package/src/modules/customers/api/deals/[id]/route.ts +2 -9
- package/src/modules/customers/api/deals/[id]/stats/route.ts +2 -9
- package/src/modules/customers/api/entity-roles-factory.ts +2 -12
- package/src/modules/customers/api/people/[id]/companies/context.ts +2 -5
- package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +2 -5
- package/src/modules/customers/api/people/[id]/route.ts +2 -5
- package/src/modules/customers/backend/customers/people/[id]/page.tsx +35 -11
- package/src/modules/directory/utils/organizationScopeGuard.ts +39 -0
- package/src/modules/progress/acl.ts +4 -0
- package/src/modules/workflows/backend/events/[id]/page.tsx +32 -10
- package/src/modules/workflows/backend/instances/[id]/page.tsx +33 -9
- package/src/modules/workflows/backend/tasks/[id]/page.tsx +33 -10
- package/src/modules/workflows/cli.ts +8 -0
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/lib/seeds.ts +13 -3
- package/src/modules/workflows/setup.ts +3 -1
|
@@ -10,6 +10,7 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
|
10
10
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
11
11
|
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
12
12
|
import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay'
|
|
13
|
+
import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
13
14
|
|
|
14
15
|
type RuleExecutionLog = {
|
|
15
16
|
id: string
|
|
@@ -54,7 +55,13 @@ export default function ExecutionLogDetailPage() {
|
|
|
54
55
|
queryFn: async () => {
|
|
55
56
|
const response = await apiFetch(`/api/business_rules/logs/${logId}`)
|
|
56
57
|
if (!response.ok) {
|
|
57
|
-
|
|
58
|
+
const httpErr = new Error(
|
|
59
|
+
response.status === 404
|
|
60
|
+
? t('business_rules.logs.errors.notFound', 'Execution log not found.')
|
|
61
|
+
: t('business_rules.logs.errors.fetchFailed')
|
|
62
|
+
) as Error & { status: number }
|
|
63
|
+
httpErr.status = response.status
|
|
64
|
+
throw httpErr
|
|
58
65
|
}
|
|
59
66
|
const result = await response.json()
|
|
60
67
|
return result as RuleExecutionLog
|
|
@@ -75,16 +82,34 @@ export default function ExecutionLogDetailPage() {
|
|
|
75
82
|
)
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
const isNotFound = !isLoading && (error as (Error & { status?: number }) | null)?.status === 404
|
|
86
|
+
|
|
87
|
+
if (isNotFound) {
|
|
88
|
+
return (
|
|
89
|
+
<Page>
|
|
90
|
+
<PageBody>
|
|
91
|
+
<RecordNotFoundState
|
|
92
|
+
label={t('business_rules.logs.errors.notFound', 'Execution log not found.')}
|
|
93
|
+
backHref="/backend/logs"
|
|
94
|
+
backLabel={t('business_rules.logs.backToList', 'Back to logs')}
|
|
95
|
+
/>
|
|
96
|
+
</PageBody>
|
|
97
|
+
</Page>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
if (error || !log) {
|
|
79
102
|
return (
|
|
80
103
|
<Page>
|
|
81
104
|
<PageBody>
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
<ErrorMessage
|
|
106
|
+
label={(error as Error | null)?.message ?? t('business_rules.logs.errors.loadFailed')}
|
|
107
|
+
action={
|
|
108
|
+
<Button asChild variant="outline" size="sm">
|
|
109
|
+
<Link href="/backend/logs">{t('business_rules.logs.backToList', 'Back to logs')}</Link>
|
|
110
|
+
</Button>
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
88
113
|
</PageBody>
|
|
89
114
|
</Page>
|
|
90
115
|
)
|
|
@@ -2,6 +2,10 @@ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
|
|
|
2
2
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
4
|
import { BusinessRule } from './data/entities'
|
|
5
|
+
import {
|
|
6
|
+
invalidateBusinessRuleDiscoveryCache,
|
|
7
|
+
resolveBusinessRuleDiscoveryCache,
|
|
8
|
+
} from './lib/rule-engine'
|
|
5
9
|
import * as fs from 'fs'
|
|
6
10
|
import * as path from 'path'
|
|
7
11
|
|
|
@@ -39,6 +43,7 @@ const seedGuardRules: ModuleCli = {
|
|
|
39
43
|
try {
|
|
40
44
|
const { resolve } = await createRequestContainer()
|
|
41
45
|
const em = resolve<EntityManager>('em')
|
|
46
|
+
const cache = resolveBusinessRuleDiscoveryCache(resolve)
|
|
42
47
|
|
|
43
48
|
// Read guard rules from workflows examples
|
|
44
49
|
const rulesPath = path.join(__dirname, '../workflows/examples', 'guard-rules-example.json')
|
|
@@ -70,6 +75,7 @@ const seedGuardRules: ModuleCli = {
|
|
|
70
75
|
})
|
|
71
76
|
|
|
72
77
|
await em.persist(rule).flush()
|
|
78
|
+
await invalidateBusinessRuleDiscoveryCache(cache, tenantId, organizationId)
|
|
73
79
|
console.log(` ✓ Seeded guard rule: ${rule.ruleName}`)
|
|
74
80
|
seededCount++
|
|
75
81
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/core'
|
|
2
|
+
import { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'
|
|
2
3
|
import type { EventBus } from '@open-mercato/events'
|
|
3
4
|
import { BusinessRule, RuleExecutionLog, type RuleType } from '../data/entities'
|
|
4
5
|
import * as ruleEvaluator from './rule-evaluator'
|
|
@@ -22,6 +23,7 @@ const EXECUTION_RESULT_FAILURE = 'FAILURE'
|
|
|
22
23
|
const MAX_RULES_PER_EXECUTION = 100
|
|
23
24
|
const MAX_SINGLE_RULE_TIMEOUT_MS = 30000 // 30 seconds
|
|
24
25
|
const MAX_TOTAL_EXECUTION_TIMEOUT_MS = 60000 // 60 seconds
|
|
26
|
+
const RULE_DISCOVERY_CACHE_TTL = 5 * 60 * 1000
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Rule execution context
|
|
@@ -86,8 +88,138 @@ export interface RuleDiscoveryOptions {
|
|
|
86
88
|
ruleType?: RuleType
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
type CachedRuleDiscovery = {
|
|
92
|
+
ruleIds: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type RuleDiscoveryCache = Pick<CacheStrategy, 'get' | 'set' | 'deleteByTags'>
|
|
96
|
+
|
|
97
|
+
export type RuleDiscoveryCacheOptions = {
|
|
98
|
+
cache?: RuleDiscoveryCache | null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeCachePart(value: string | null | undefined): string {
|
|
102
|
+
return encodeURIComponent(value?.trim() || '*')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getRuleDiscoveryCacheKey(options: RuleDiscoveryOptions): string {
|
|
106
|
+
return [
|
|
107
|
+
normalizeCachePart(options.tenantId),
|
|
108
|
+
normalizeCachePart(options.organizationId),
|
|
109
|
+
normalizeCachePart(options.entityType),
|
|
110
|
+
normalizeCachePart(options.eventType),
|
|
111
|
+
normalizeCachePart(options.ruleType),
|
|
112
|
+
].join(':')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isCachedRuleDiscovery(value: unknown): value is CachedRuleDiscovery {
|
|
116
|
+
if (!value || typeof value !== 'object') return false
|
|
117
|
+
const ruleIds = (value as CachedRuleDiscovery).ruleIds
|
|
118
|
+
return Array.isArray(ruleIds) && ruleIds.every((entry) => typeof entry === 'string')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function isRuleDiscoveryCache(value: unknown): value is RuleDiscoveryCache {
|
|
122
|
+
if (!value || typeof value !== 'object') return false
|
|
123
|
+
const candidate = value as Partial<RuleDiscoveryCache>
|
|
124
|
+
return (
|
|
125
|
+
typeof candidate.get === 'function'
|
|
126
|
+
&& typeof candidate.set === 'function'
|
|
127
|
+
&& typeof candidate.deleteByTags === 'function'
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function resolveBusinessRuleDiscoveryCache(resolve: (token: string) => unknown): RuleDiscoveryCache | null {
|
|
132
|
+
try {
|
|
133
|
+
const cache = resolve('cache')
|
|
134
|
+
return isRuleDiscoveryCache(cache) ? cache : null
|
|
135
|
+
} catch {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getRuleDiscoveryCacheTags(options: Pick<RuleDiscoveryOptions, 'organizationId'>): string[] {
|
|
141
|
+
return [
|
|
142
|
+
'business_rules:discovery',
|
|
143
|
+
`business_rules:discovery:organization:${options.organizationId}`,
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function getCachedRuleDiscovery(
|
|
148
|
+
cache: RuleDiscoveryCache | null | undefined,
|
|
149
|
+
options: RuleDiscoveryOptions,
|
|
150
|
+
): Promise<CachedRuleDiscovery | null> {
|
|
151
|
+
if (!cache) return null
|
|
152
|
+
|
|
153
|
+
let value: unknown
|
|
154
|
+
try {
|
|
155
|
+
value = await runWithCacheTenant(options.tenantId, () =>
|
|
156
|
+
cache.get(getRuleDiscoveryCacheKey(options))
|
|
157
|
+
)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.warn('[business_rules] Failed to read rule discovery cache:', error)
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return isCachedRuleDiscovery(value) ? value : null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function cacheRuleDiscovery(
|
|
167
|
+
cache: RuleDiscoveryCache | null | undefined,
|
|
168
|
+
options: RuleDiscoveryOptions,
|
|
169
|
+
rules: BusinessRule[],
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
if (!cache) return
|
|
172
|
+
|
|
173
|
+
const ruleIds = rules
|
|
174
|
+
.map((rule) => rule.id)
|
|
175
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
176
|
+
|
|
177
|
+
if (ruleIds.length !== rules.length) return
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await runWithCacheTenant(options.tenantId, () =>
|
|
181
|
+
cache.set(
|
|
182
|
+
getRuleDiscoveryCacheKey(options),
|
|
183
|
+
{ ruleIds },
|
|
184
|
+
{
|
|
185
|
+
ttl: RULE_DISCOVERY_CACHE_TTL,
|
|
186
|
+
tags: getRuleDiscoveryCacheTags(options),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.warn('[business_rules] Failed to write rule discovery cache:', error)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function invalidateBusinessRuleDiscoveryCache(
|
|
196
|
+
cache: RuleDiscoveryCache | null | undefined,
|
|
197
|
+
tenantId?: string | null,
|
|
198
|
+
organizationId?: string | null,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
if (!cache) return
|
|
201
|
+
|
|
202
|
+
const normalizedTenantId = tenantId?.trim()
|
|
203
|
+
const normalizedOrganizationId = organizationId?.trim()
|
|
204
|
+
|
|
205
|
+
if (!normalizedTenantId) {
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const tags = normalizedOrganizationId
|
|
210
|
+
? [`business_rules:discovery:organization:${normalizedOrganizationId}`]
|
|
211
|
+
: ['business_rules:discovery']
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await runWithCacheTenant(normalizedTenantId, () => cache.deleteByTags(tags))
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.warn('[business_rules] Failed to invalidate rule discovery cache:', error)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
89
220
|
export type RuleEngineExecutionOptions = {
|
|
90
221
|
eventBus?: Pick<EventBus, 'emitEvent'> | null
|
|
222
|
+
cache?: RuleDiscoveryCache | null
|
|
91
223
|
}
|
|
92
224
|
|
|
93
225
|
type RuleExecutionFailedPayload = {
|
|
@@ -213,7 +345,7 @@ export async function executeRules(
|
|
|
213
345
|
eventType: context.eventType,
|
|
214
346
|
tenantId: context.tenantId,
|
|
215
347
|
organizationId: context.organizationId,
|
|
216
|
-
})
|
|
348
|
+
}, { cache: options.cache })
|
|
217
349
|
|
|
218
350
|
// Check rule count limit
|
|
219
351
|
if (rules.length > MAX_RULES_PER_EXECUTION) {
|
|
@@ -468,14 +600,14 @@ export async function executeSingleRule(
|
|
|
468
600
|
*/
|
|
469
601
|
export async function findApplicableRules(
|
|
470
602
|
em: EntityManager,
|
|
471
|
-
options: RuleDiscoveryOptions
|
|
603
|
+
options: RuleDiscoveryOptions,
|
|
604
|
+
cacheOptions: RuleDiscoveryCacheOptions = {},
|
|
472
605
|
): Promise<BusinessRule[]> {
|
|
473
606
|
// Validate input
|
|
474
607
|
ruleDiscoveryOptionsSchema.parse(options)
|
|
475
608
|
|
|
476
609
|
const { entityType, eventType, tenantId, organizationId, ruleType } = options
|
|
477
|
-
|
|
478
|
-
const where: Partial<BusinessRule> = {
|
|
610
|
+
const baseWhere: Record<string, unknown> = {
|
|
479
611
|
entityType,
|
|
480
612
|
tenantId,
|
|
481
613
|
organizationId,
|
|
@@ -484,16 +616,38 @@ export async function findApplicableRules(
|
|
|
484
616
|
}
|
|
485
617
|
|
|
486
618
|
if (eventType) {
|
|
487
|
-
|
|
619
|
+
baseWhere.eventType = eventType
|
|
488
620
|
}
|
|
489
621
|
|
|
490
622
|
if (ruleType) {
|
|
491
|
-
|
|
623
|
+
baseWhere.ruleType = ruleType
|
|
492
624
|
}
|
|
493
625
|
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
626
|
+
const cached = await getCachedRuleDiscovery(cacheOptions.cache, options)
|
|
627
|
+
let rules: BusinessRule[]
|
|
628
|
+
|
|
629
|
+
if (cached) {
|
|
630
|
+
if (cached.ruleIds.length === 0) {
|
|
631
|
+
rules = []
|
|
632
|
+
} else {
|
|
633
|
+
const cachedRules = await em.find(BusinessRule, {
|
|
634
|
+
...baseWhere,
|
|
635
|
+
id: { $in: cached.ruleIds },
|
|
636
|
+
}, {
|
|
637
|
+
orderBy: { priority: 'DESC', ruleId: 'ASC' },
|
|
638
|
+
} as any)
|
|
639
|
+
const byId = new Map(cachedRules.map((rule) => [rule.id, rule]))
|
|
640
|
+
rules = cached.ruleIds
|
|
641
|
+
.map((id) => byId.get(id))
|
|
642
|
+
.filter((rule): rule is BusinessRule => Boolean(rule))
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
rules = await em.find(BusinessRule, baseWhere as Partial<BusinessRule>, {
|
|
646
|
+
orderBy: { priority: 'DESC', ruleId: 'ASC' },
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
await cacheRuleDiscovery(cacheOptions.cache, options, rules)
|
|
650
|
+
}
|
|
497
651
|
|
|
498
652
|
// Filter by effective date range
|
|
499
653
|
const now = new Date()
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { EntityManager } from '@mikro-orm/core'
|
|
14
|
-
import { executeRules } from '../lib/rule-engine'
|
|
14
|
+
import { executeRules, resolveBusinessRuleDiscoveryCache } from '../lib/rule-engine'
|
|
15
15
|
|
|
16
16
|
export const metadata = {
|
|
17
17
|
event: '*',
|
|
@@ -64,6 +64,7 @@ export default async function handle(
|
|
|
64
64
|
if (!tenantId || !organizationId) return
|
|
65
65
|
|
|
66
66
|
const em = ctx.resolve<EntityManager>('em')
|
|
67
|
+
const cache = resolveBusinessRuleDiscoveryCache(ctx.resolve)
|
|
67
68
|
|
|
68
69
|
try {
|
|
69
70
|
await executeRules(em, {
|
|
@@ -72,7 +73,7 @@ export default async function handle(
|
|
|
72
73
|
data,
|
|
73
74
|
tenantId,
|
|
74
75
|
organizationId,
|
|
75
|
-
})
|
|
76
|
+
}, { cache })
|
|
76
77
|
} catch (error) {
|
|
77
78
|
console.error(`[business_rules] Rule execution failed for event ${eventName}:`, error)
|
|
78
79
|
}
|
|
@@ -94,11 +94,25 @@ export async function decorateOffersWithDetails(
|
|
|
94
94
|
.filter((value): value is string => !!value)
|
|
95
95
|
if (!offerIds.length && !productIds.length) return
|
|
96
96
|
const em = ctx.container.resolve('em') as EntityManager
|
|
97
|
+
const scopeTenantId = ctx.auth?.tenantId ?? null
|
|
98
|
+
if (!scopeTenantId) {
|
|
99
|
+
throw new CrudHttpError(403, '[internal] Missing tenant scope for offer decoration')
|
|
100
|
+
}
|
|
101
|
+
const scopeOrgIds =
|
|
102
|
+
Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
|
|
103
|
+
? Array.from(new Set(ctx.organizationIds))
|
|
104
|
+
: (ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null)
|
|
105
|
+
? [(ctx.selectedOrganizationId ?? ctx.auth?.orgId) as string]
|
|
106
|
+
: []
|
|
107
|
+
const scopeWhere: Record<string, unknown> = { tenantId: scopeTenantId }
|
|
108
|
+
if (scopeOrgIds.length === 1) scopeWhere.organizationId = scopeOrgIds[0]
|
|
109
|
+
else if (scopeOrgIds.length > 1) scopeWhere.organizationId = { $in: scopeOrgIds }
|
|
110
|
+
const scope = { tenantId: scopeTenantId, organizationId: scopeOrgIds.length === 1 ? scopeOrgIds[0] : null }
|
|
97
111
|
const [products, prices, defaultVariants] = await Promise.all([
|
|
98
112
|
productIds.length
|
|
99
113
|
? em.find(
|
|
100
114
|
CatalogProduct,
|
|
101
|
-
{ id: { $in: productIds } },
|
|
115
|
+
{ id: { $in: productIds }, ...scopeWhere },
|
|
102
116
|
{
|
|
103
117
|
fields: ['id', 'title', 'description', 'defaultMediaId', 'defaultMediaUrl', 'sku'],
|
|
104
118
|
},
|
|
@@ -108,15 +122,15 @@ export async function decorateOffersWithDetails(
|
|
|
108
122
|
? findWithDecryption(
|
|
109
123
|
em,
|
|
110
124
|
CatalogProductPrice,
|
|
111
|
-
{ offer: { $in: offerIds } },
|
|
125
|
+
{ offer: { $in: offerIds }, ...scopeWhere },
|
|
112
126
|
{ populate: ['priceKind'] },
|
|
113
|
-
|
|
127
|
+
scope,
|
|
114
128
|
)
|
|
115
129
|
: [],
|
|
116
130
|
productIds.length
|
|
117
131
|
? em.find(
|
|
118
132
|
CatalogProductVariant,
|
|
119
|
-
{ product: { $in: productIds }, isDefault: true },
|
|
133
|
+
{ product: { $in: productIds }, isDefault: true, ...scopeWhere },
|
|
120
134
|
{ fields: ['id', 'product'] },
|
|
121
135
|
)
|
|
122
136
|
: [],
|
|
@@ -227,6 +241,7 @@ export async function decorateOffersWithDetails(
|
|
|
227
241
|
CatalogProductPrice,
|
|
228
242
|
{
|
|
229
243
|
offer: null,
|
|
244
|
+
...scopeWhere,
|
|
230
245
|
$and: [
|
|
231
246
|
{ $or: fallbackTargets },
|
|
232
247
|
channelFilterValues.includes(null)
|
|
@@ -240,7 +255,7 @@ export async function decorateOffersWithDetails(
|
|
|
240
255
|
],
|
|
241
256
|
},
|
|
242
257
|
{ populate: ['priceKind'] },
|
|
243
|
-
|
|
258
|
+
scope,
|
|
244
259
|
)
|
|
245
260
|
: []
|
|
246
261
|
fallbackEntries.forEach((entry) => {
|
|
@@ -628,9 +628,13 @@ async function decorateProductsAfterList(
|
|
|
628
628
|
"catalogPricingService",
|
|
629
629
|
);
|
|
630
630
|
|
|
631
|
+
const pricingEntries: Array<{ rows: PriceRow[]; context: PricingContext } | null> = [];
|
|
631
632
|
for (const item of items) {
|
|
632
633
|
const id = typeof item.id === "string" ? item.id : null;
|
|
633
|
-
if (!id)
|
|
634
|
+
if (!id) {
|
|
635
|
+
pricingEntries.push(null);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
634
638
|
const offerEntries = offersByProduct.get(id) ?? [];
|
|
635
639
|
item.offers = offerEntries;
|
|
636
640
|
const channelIds = Array.from(
|
|
@@ -668,10 +672,25 @@ async function decorateProductsAfterList(
|
|
|
668
672
|
pricingContext.channelId || channelIds.length !== 1
|
|
669
673
|
? pricingContext
|
|
670
674
|
: { ...pricingContext, channelId: channelIds[0] };
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
quantity: normalizedQuantityForPricing,
|
|
675
|
+
pricingEntries.push({
|
|
676
|
+
rows: priceCandidates,
|
|
677
|
+
context: { ...channelScopedContext, quantity: normalizedQuantityForPricing },
|
|
674
678
|
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const resolveInputs: Array<{ rows: PriceRow[]; context: PricingContext }> = [];
|
|
682
|
+
const resolveIndices: number[] = [];
|
|
683
|
+
for (let i = 0; i < pricingEntries.length; i++) {
|
|
684
|
+
if (pricingEntries[i] !== null) {
|
|
685
|
+
resolveInputs.push(pricingEntries[i]!);
|
|
686
|
+
resolveIndices.push(i);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const priceResults = await pricingService.resolvePriceMany(resolveInputs);
|
|
690
|
+
|
|
691
|
+
for (let i = 0; i < resolveIndices.length; i++) {
|
|
692
|
+
const item = items[resolveIndices[i]];
|
|
693
|
+
const best = priceResults[i];
|
|
675
694
|
if (best) {
|
|
676
695
|
item.pricing = {
|
|
677
696
|
kind: resolvePriceKindCode(best),
|
|
@@ -180,3 +180,12 @@ export async function resolveCatalogPrice(
|
|
|
180
180
|
|
|
181
181
|
return resolved ?? null
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
export async function resolveCatalogPriceBatch(
|
|
185
|
+
entries: Array<{ rows: PriceRow[]; context: PricingContext }>,
|
|
186
|
+
options?: { eventBus?: EventBus | null }
|
|
187
|
+
): Promise<Array<PriceRow | null>> {
|
|
188
|
+
return Promise.all(
|
|
189
|
+
entries.map(({ rows, context }) => resolveCatalogPrice(rows, context, options))
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { EventBus } from '@open-mercato/events'
|
|
2
2
|
import {
|
|
3
3
|
resolveCatalogPrice,
|
|
4
|
+
resolveCatalogPriceBatch,
|
|
4
5
|
type PriceRow,
|
|
5
6
|
type PricingContext,
|
|
6
7
|
} from '../lib/pricing'
|
|
7
8
|
|
|
8
9
|
export interface CatalogPricingService {
|
|
9
10
|
resolvePrice(rows: PriceRow[], context: PricingContext): Promise<PriceRow | null>
|
|
11
|
+
resolvePriceMany(entries: Array<{ rows: PriceRow[]; context: PricingContext }>): Promise<Array<PriceRow | null>>
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export class DefaultCatalogPricingService implements CatalogPricingService {
|
|
@@ -15,6 +17,10 @@ export class DefaultCatalogPricingService implements CatalogPricingService {
|
|
|
15
17
|
async resolvePrice(rows: PriceRow[], context: PricingContext): Promise<PriceRow | null> {
|
|
16
18
|
return resolveCatalogPrice(rows, context, { eventBus: this.eventBus })
|
|
17
19
|
}
|
|
20
|
+
|
|
21
|
+
async resolvePriceMany(entries: Array<{ rows: PriceRow[]; context: PricingContext }>): Promise<Array<PriceRow | null>> {
|
|
22
|
+
return resolveCatalogPriceBatch(entries, { eventBus: this.eventBus })
|
|
23
|
+
}
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export type { PriceRow, PricingContext }
|
|
@@ -12,6 +12,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
12
12
|
import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
|
|
13
13
|
import { DataLoader } from '@open-mercato/ui/primitives/DataLoader'
|
|
14
14
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
15
|
+
import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
15
16
|
|
|
16
17
|
type CurrencyData = {
|
|
17
18
|
id: string
|
|
@@ -35,6 +36,7 @@ export default function EditCurrencyPage({ params }: { params?: { id?: string }
|
|
|
35
36
|
const [currency, setCurrency] = React.useState<CurrencyData | null>(null)
|
|
36
37
|
const [loading, setLoading] = React.useState(true)
|
|
37
38
|
const [error, setError] = React.useState<string | null>(null)
|
|
39
|
+
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
38
40
|
|
|
39
41
|
React.useEffect(() => {
|
|
40
42
|
async function loadCurrency() {
|
|
@@ -42,8 +44,10 @@ export default function EditCurrencyPage({ params }: { params?: { id?: string }
|
|
|
42
44
|
const response = await apiCall<{ items: CurrencyData[] }>(`/api/currencies/currencies?id=${params?.id}`)
|
|
43
45
|
if (response.ok && response.result && response.result.items.length > 0) {
|
|
44
46
|
setCurrency(response.result.items[0])
|
|
47
|
+
} else if (!response.ok) {
|
|
48
|
+
setError(t('currencies.form.errors.load'))
|
|
45
49
|
} else {
|
|
46
|
-
|
|
50
|
+
setIsNotFound(true)
|
|
47
51
|
}
|
|
48
52
|
} catch (err) {
|
|
49
53
|
setError(t('currencies.form.errors.load'))
|
|
@@ -163,11 +167,26 @@ export default function EditCurrencyPage({ params }: { params?: { id?: string }
|
|
|
163
167
|
)
|
|
164
168
|
}
|
|
165
169
|
|
|
170
|
+
if (isNotFound) {
|
|
171
|
+
return (
|
|
172
|
+
<Page>
|
|
173
|
+
<PageBody>
|
|
174
|
+
<RecordNotFoundState
|
|
175
|
+
label={t('currencies.form.errors.notFound', 'Currency not found.')}
|
|
176
|
+
backHref="/backend/currencies"
|
|
177
|
+
backLabel={t('currencies.form.actions.backToList', 'Back to currencies')}
|
|
178
|
+
/>
|
|
179
|
+
</PageBody>
|
|
180
|
+
{ConfirmDialogElement}
|
|
181
|
+
</Page>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
166
185
|
if (error || !currency) {
|
|
167
186
|
return (
|
|
168
187
|
<Page>
|
|
169
188
|
<PageBody>
|
|
170
|
-
<
|
|
189
|
+
<ErrorMessage label={error ?? t('currencies.form.errors.notFound', 'Currency not found.')} />
|
|
171
190
|
</PageBody>
|
|
172
191
|
{ConfirmDialogElement}
|
|
173
192
|
</Page>
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"currencies.flash.updated": "Währung erfolgreich aktualisiert",
|
|
42
42
|
"currencies.form.action.create": "Währung erstellen",
|
|
43
43
|
"currencies.form.action.save": "Änderungen speichern",
|
|
44
|
+
"currencies.form.actions.backToList": "Zurück zu Währungen",
|
|
44
45
|
"currencies.form.errors.codeFormat": "Währungscode muss genau 3 Großbuchstaben sein (z.B. USD)",
|
|
45
46
|
"currencies.form.errors.delete": "Währung konnte nicht gelöscht werden",
|
|
46
47
|
"currencies.form.errors.load": "Währung konnte nicht geladen werden",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"currencies.flash.updated": "Currency updated successfully",
|
|
42
42
|
"currencies.form.action.create": "Create Currency",
|
|
43
43
|
"currencies.form.action.save": "Save Changes",
|
|
44
|
+
"currencies.form.actions.backToList": "Back to currencies",
|
|
44
45
|
"currencies.form.errors.codeFormat": "Currency code must be exactly 3 uppercase letters (e.g., USD)",
|
|
45
46
|
"currencies.form.errors.delete": "Failed to delete currency",
|
|
46
47
|
"currencies.form.errors.load": "Failed to load currency",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"currencies.flash.updated": "Moneda actualizada correctamente",
|
|
42
42
|
"currencies.form.action.create": "Crear Moneda",
|
|
43
43
|
"currencies.form.action.save": "Guardar Cambios",
|
|
44
|
+
"currencies.form.actions.backToList": "Volver a divisas",
|
|
44
45
|
"currencies.form.errors.codeFormat": "El código de moneda debe ser exactamente 3 letras mayúsculas (ej. USD)",
|
|
45
46
|
"currencies.form.errors.delete": "Error al eliminar moneda",
|
|
46
47
|
"currencies.form.errors.load": "Error al cargar moneda",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"currencies.flash.updated": "Waluta zaktualizowana pomyślnie",
|
|
42
42
|
"currencies.form.action.create": "Utwórz Walutę",
|
|
43
43
|
"currencies.form.action.save": "Zapisz Zmiany",
|
|
44
|
+
"currencies.form.actions.backToList": "Wróć do walut",
|
|
44
45
|
"currencies.form.errors.codeFormat": "Kod waluty musi składać się z dokładnie 3 wielkich liter (np. USD)",
|
|
45
46
|
"currencies.form.errors.delete": "Nie udało się usunąć waluty",
|
|
46
47
|
"currencies.form.errors.load": "Nie udało się załadować waluty",
|
|
@@ -11,6 +11,7 @@ import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
|
11
11
|
import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
12
12
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
13
13
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
14
|
+
import { RecordNotFoundState, ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
14
15
|
|
|
15
16
|
type RoleDetail = {
|
|
16
17
|
id: string
|
|
@@ -146,10 +147,11 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
|
|
|
146
147
|
const [data, setData] = React.useState<RoleDetail | null>(null)
|
|
147
148
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
148
149
|
const [error, setError] = React.useState<string | null>(null)
|
|
150
|
+
const [isNotFound, setIsNotFound] = React.useState(false)
|
|
149
151
|
|
|
150
152
|
React.useEffect(() => {
|
|
151
153
|
if (!id) {
|
|
152
|
-
|
|
154
|
+
setIsNotFound(true)
|
|
153
155
|
setIsLoading(false)
|
|
154
156
|
return
|
|
155
157
|
}
|
|
@@ -157,6 +159,7 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
|
|
|
157
159
|
async function load() {
|
|
158
160
|
setIsLoading(true)
|
|
159
161
|
setError(null)
|
|
162
|
+
setIsNotFound(false)
|
|
160
163
|
try {
|
|
161
164
|
const payload = await readApiResultOrThrow<RoleDetail>(
|
|
162
165
|
`/api/customer_accounts/admin/roles/${encodeURIComponent(id!)}`,
|
|
@@ -167,8 +170,12 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
|
|
|
167
170
|
setData(payload)
|
|
168
171
|
} catch (err) {
|
|
169
172
|
if (cancelled) return
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
if ((err as { status?: number }).status === 404) {
|
|
174
|
+
setIsNotFound(true)
|
|
175
|
+
} else {
|
|
176
|
+
const message = err instanceof Error ? err.message : t('customer_accounts.admin.roleDetail.error.load', 'Failed to load role')
|
|
177
|
+
setError(message)
|
|
178
|
+
}
|
|
172
179
|
} finally {
|
|
173
180
|
if (!cancelled) setIsLoading(false)
|
|
174
181
|
}
|
|
@@ -300,18 +307,34 @@ export default function CustomerRoleDetailPage({ params }: { params?: { id?: str
|
|
|
300
307
|
)
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
if (isNotFound) {
|
|
311
|
+
return (
|
|
312
|
+
<Page>
|
|
313
|
+
<PageBody>
|
|
314
|
+
<RecordNotFoundState
|
|
315
|
+
label={t('customer_accounts.admin.roleDetail.error.notFound', 'Role not found')}
|
|
316
|
+
backHref="/backend/customer_accounts/roles"
|
|
317
|
+
backLabel={t('customer_accounts.admin.roleDetail.actions.backToList', 'Back to roles')}
|
|
318
|
+
/>
|
|
319
|
+
</PageBody>
|
|
320
|
+
</Page>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
303
324
|
if (error || !data) {
|
|
304
325
|
return (
|
|
305
326
|
<Page>
|
|
306
327
|
<PageBody>
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
<
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
328
|
+
<ErrorMessage
|
|
329
|
+
label={error ?? t('customer_accounts.admin.roleDetail.error.notFound', 'Role not found')}
|
|
330
|
+
action={
|
|
331
|
+
<Button asChild variant="outline" size="sm">
|
|
332
|
+
<Link href="/backend/customer_accounts/roles">
|
|
333
|
+
{t('customer_accounts.admin.roleDetail.actions.backToList', 'Back to roles')}
|
|
334
|
+
</Link>
|
|
335
|
+
</Button>
|
|
336
|
+
}
|
|
337
|
+
/>
|
|
315
338
|
</PageBody>
|
|
316
339
|
</Page>
|
|
317
340
|
)
|