@open-mercato/core 0.8.1-develop.7207.1.901ef7e47a → 0.8.1-develop.7215.1.60786cf836
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/catalog/backend/catalog/products/[id]/page.js +6 -2
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/create/page.js +7 -3
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductUomSection.js +11 -22
- package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
- package/dist/modules/catalog/components/products/productForm.js +26 -1
- package/dist/modules/catalog/components/products/productForm.js.map +2 -2
- package/dist/modules/configs/lib/upgrade-actions.js +1 -0
- package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
- package/dist/modules/configs/services/upgradeActionsService.js +9 -2
- package/dist/modules/configs/services/upgradeActionsService.js.map +2 -2
- package/dist/modules/entities/lib/definition-scope-where.js +18 -0
- package/dist/modules/entities/lib/definition-scope-where.js.map +7 -0
- package/dist/modules/entities/lib/definition-scope.js +2 -8
- package/dist/modules/entities/lib/definition-scope.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +4 -30
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/scoped-field-defs.js +5 -10
- package/dist/modules/entities/lib/scoped-field-defs.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js.map +1 -1
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +15 -1
- package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +6 -2
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +7 -6
- package/src/modules/catalog/components/products/ProductUomSection.tsx +11 -31
- package/src/modules/catalog/components/products/productForm.ts +53 -0
- package/src/modules/configs/lib/upgrade-actions.ts +7 -0
- package/src/modules/configs/services/upgradeActionsService.ts +17 -2
- package/src/modules/entities/lib/definition-scope-where.ts +27 -0
- package/src/modules/entities/lib/definition-scope.ts +2 -10
- package/src/modules/entities/lib/helpers.ts +4 -33
- package/src/modules/entities/lib/scoped-field-defs.ts +6 -15
- package/src/modules/query_index/lib/engine.ts +4 -4
- package/src/modules/query_index/lib/search-tokens.ts +31 -0
- package/src/modules/wms/components/backend/AdjustInventoryDialog.tsx +21 -1
|
@@ -57,15 +57,6 @@ const REFERENCE_UNIT_OPTIONS = REFERENCE_UNIT_CODES.map((code) => ({
|
|
|
57
57
|
fallback: REFERENCE_UNIT_DISPLAY[code] ?? code,
|
|
58
58
|
}));
|
|
59
59
|
|
|
60
|
-
// Keeps the field submit-ready (dot-decimal, no grouping characters) while the user is
|
|
61
|
-
// still typing. Falls back to the raw value when it isn't parseable yet (empty, a lone
|
|
62
|
-
// sign) so the field stays editable. Delegates to `parseLocaleNumber` instead of a
|
|
63
|
-
// hard-coded `,` -> `.` swap, which broke on grouped input such as `1 234,56` (#5828).
|
|
64
|
-
export function normalizeDecimalInput(value: string, locale?: string): string {
|
|
65
|
-
const parsed = parseLocaleNumber(value, locale);
|
|
66
|
-
return parsed === null ? value : String(parsed);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
60
|
export function toPositiveNumber(value: unknown, locale?: string): number | null {
|
|
70
61
|
if (typeof value === "number") {
|
|
71
62
|
return Number.isFinite(value) && value > 0 ? value : null;
|
|
@@ -88,10 +79,7 @@ function formatPreviewNumber(value: number): string {
|
|
|
88
79
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toString();
|
|
89
80
|
}
|
|
90
81
|
|
|
91
|
-
function normalizeConversions(
|
|
92
|
-
value: unknown,
|
|
93
|
-
locale?: string,
|
|
94
|
-
): ProductUnitConversionDraft[] {
|
|
82
|
+
function normalizeConversions(value: unknown): ProductUnitConversionDraft[] {
|
|
95
83
|
if (!Array.isArray(value)) return [];
|
|
96
84
|
const normalized = value
|
|
97
85
|
.map((entry) => {
|
|
@@ -100,9 +88,9 @@ function normalizeConversions(
|
|
|
100
88
|
return {
|
|
101
89
|
id: toTrimmedOrNull(row.id) ?? null,
|
|
102
90
|
unitCode: toTrimmedOrNull(row.unitCode) ?? "",
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
// Stays raw while editing (issue #5828) — re-serializing on every render would drop
|
|
92
|
+
// an in-progress decimal separator the instant it's typed.
|
|
93
|
+
toBaseFactor: toTrimmedOrNull(row.toBaseFactor) ?? "",
|
|
106
94
|
sortOrder: toTrimmedOrNull(row.sortOrder) ?? "",
|
|
107
95
|
isActive: row.isActive !== false,
|
|
108
96
|
} satisfies ProductUnitConversionDraft;
|
|
@@ -150,8 +138,8 @@ export function ProductUomSection({
|
|
|
150
138
|
const [loadingUnits, setLoadingUnits] = React.useState(false);
|
|
151
139
|
const [errorLoadingUnits, setErrorLoadingUnits] = React.useState(false);
|
|
152
140
|
const conversions = React.useMemo(
|
|
153
|
-
() => normalizeConversions(values.unitConversions
|
|
154
|
-
[values.unitConversions
|
|
141
|
+
() => normalizeConversions(values.unitConversions),
|
|
142
|
+
[values.unitConversions],
|
|
155
143
|
);
|
|
156
144
|
|
|
157
145
|
React.useEffect(() => {
|
|
@@ -246,15 +234,13 @@ export function ProductUomSection({
|
|
|
246
234
|
|
|
247
235
|
const defaultUnit = toTrimmedOrNull(values.defaultUnit) ?? "";
|
|
248
236
|
const defaultSalesUnit = toTrimmedOrNull(values.defaultSalesUnit) ?? "";
|
|
249
|
-
const
|
|
237
|
+
const defaultSalesQuantity =
|
|
250
238
|
toTrimmedOrNull(values.defaultSalesUnitQuantity) ?? "1";
|
|
251
|
-
const defaultSalesQuantity = normalizeDecimalInput(defaultSalesQuantityRaw, locale);
|
|
252
239
|
const unitPriceEnabled = Boolean(values.unitPriceEnabled);
|
|
253
240
|
const unitPriceReferenceUnit =
|
|
254
241
|
toTrimmedOrNull(values.unitPriceReferenceUnit) ?? "";
|
|
255
|
-
const
|
|
242
|
+
const unitPriceBaseQuantity =
|
|
256
243
|
toTrimmedOrNull(values.unitPriceBaseQuantity) ?? "";
|
|
257
|
-
const unitPriceBaseQuantity = normalizeDecimalInput(unitPriceBaseQuantityRaw, locale);
|
|
258
244
|
|
|
259
245
|
const baseUnitLabel = findUnitLabel(defaultUnit) ?? defaultUnit;
|
|
260
246
|
const salesUnitLabel =
|
|
@@ -396,10 +382,7 @@ export function ProductUomSection({
|
|
|
396
382
|
inputMode="decimal"
|
|
397
383
|
value={defaultSalesQuantity}
|
|
398
384
|
onChange={(event) =>
|
|
399
|
-
setValue(
|
|
400
|
-
"defaultSalesUnitQuantity",
|
|
401
|
-
normalizeDecimalInput(event.target.value, locale),
|
|
402
|
-
)
|
|
385
|
+
setValue("defaultSalesUnitQuantity", event.target.value)
|
|
403
386
|
}
|
|
404
387
|
placeholder="1"
|
|
405
388
|
/>
|
|
@@ -549,10 +532,7 @@ export function ProductUomSection({
|
|
|
549
532
|
inputMode="decimal"
|
|
550
533
|
value={unitPriceBaseQuantity}
|
|
551
534
|
onChange={(event) =>
|
|
552
|
-
setValue(
|
|
553
|
-
"unitPriceBaseQuantity",
|
|
554
|
-
normalizeDecimalInput(event.target.value, locale),
|
|
555
|
-
)
|
|
535
|
+
setValue("unitPriceBaseQuantity", event.target.value)
|
|
556
536
|
}
|
|
557
537
|
placeholder="1"
|
|
558
538
|
/>
|
|
@@ -663,7 +643,7 @@ export function ProductUomSection({
|
|
|
663
643
|
value={entry.toBaseFactor}
|
|
664
644
|
onChange={(event) =>
|
|
665
645
|
updateConversion(index, {
|
|
666
|
-
toBaseFactor:
|
|
646
|
+
toBaseFactor: event.target.value,
|
|
667
647
|
})
|
|
668
648
|
}
|
|
669
649
|
placeholder="1"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { slugify } from "@open-mercato/shared/lib/slugify";
|
|
3
3
|
import { parseObjectLike } from "@open-mercato/shared/lib/json/parseObjectLike";
|
|
4
|
+
import { parseLocaleNumber } from "@open-mercato/shared/lib/number";
|
|
4
5
|
import type { ReferenceUnitCode } from "@open-mercato/shared/lib/units/unitCodes";
|
|
5
6
|
import {
|
|
6
7
|
CATALOG_CONFIGURABLE_PRODUCT_TYPES,
|
|
@@ -348,6 +349,58 @@ export const productFormSchema = z
|
|
|
348
349
|
{ message: 'catalog.products.validation.availabilityDateRange', path: ['availableUntil'] }
|
|
349
350
|
);
|
|
350
351
|
|
|
352
|
+
// The UoM fields below stay raw locale-typed text while the user is editing them
|
|
353
|
+
// (`ProductUomSection`, issue #5828), so `z.coerce.number()` — which only accepts a dot
|
|
354
|
+
// decimal separator — cannot parse them directly. Convert to a canonical dot-decimal string
|
|
355
|
+
// right before validation; an unparseable value is left as-is so the schema's own "expected
|
|
356
|
+
// number" error still fires.
|
|
357
|
+
function toCanonicalDecimalString(raw: string, locale?: string): string {
|
|
358
|
+
if (!raw.trim()) return raw;
|
|
359
|
+
const parsed = parseLocaleNumber(raw, locale);
|
|
360
|
+
return parsed === null ? raw : String(parsed);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function withCanonicalUomFields(
|
|
364
|
+
values: ProductFormValues,
|
|
365
|
+
locale?: string,
|
|
366
|
+
): ProductFormValues {
|
|
367
|
+
return {
|
|
368
|
+
...values,
|
|
369
|
+
defaultSalesUnitQuantity:
|
|
370
|
+
typeof values.defaultSalesUnitQuantity === "string"
|
|
371
|
+
? toCanonicalDecimalString(values.defaultSalesUnitQuantity, locale)
|
|
372
|
+
: values.defaultSalesUnitQuantity,
|
|
373
|
+
unitPriceBaseQuantity:
|
|
374
|
+
typeof values.unitPriceBaseQuantity === "string"
|
|
375
|
+
? toCanonicalDecimalString(values.unitPriceBaseQuantity, locale)
|
|
376
|
+
: values.unitPriceBaseQuantity,
|
|
377
|
+
unitConversions: Array.isArray(values.unitConversions)
|
|
378
|
+
? values.unitConversions.map((conversion) => ({
|
|
379
|
+
...conversion,
|
|
380
|
+
toBaseFactor:
|
|
381
|
+
typeof conversion?.toBaseFactor === "string"
|
|
382
|
+
? toCanonicalDecimalString(conversion.toBaseFactor, locale)
|
|
383
|
+
: conversion?.toBaseFactor,
|
|
384
|
+
}))
|
|
385
|
+
: values.unitConversions,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// `CrudForm`'s `schema` prop runs validation internally, before `onSubmit` — so a page that
|
|
390
|
+
// passes `productFormSchema` there directly (rather than calling `.safeParse` by hand inside
|
|
391
|
+
// `onSubmit`, as the edit page does) needs the UoM canonicalization baked into the schema
|
|
392
|
+
// itself. `locale` is only known at render time, so this builds the wrapped schema per-locale
|
|
393
|
+
// rather than exporting a single module-level constant.
|
|
394
|
+
export function buildLocaleAwareProductFormSchema(locale?: string): z.ZodType<ProductFormValues> {
|
|
395
|
+
return z.preprocess(
|
|
396
|
+
(raw) =>
|
|
397
|
+
raw && typeof raw === "object"
|
|
398
|
+
? withCanonicalUomFields(raw as ProductFormValues, locale)
|
|
399
|
+
: raw,
|
|
400
|
+
productFormSchema,
|
|
401
|
+
) as unknown as z.ZodType<ProductFormValues>;
|
|
402
|
+
}
|
|
403
|
+
|
|
351
404
|
export const PRODUCT_FORM_STEPS = [
|
|
352
405
|
"general",
|
|
353
406
|
"organize",
|
|
@@ -21,6 +21,12 @@ export type UpgradeActionDefinition = {
|
|
|
21
21
|
ctaKey: string
|
|
22
22
|
successKey: string
|
|
23
23
|
loadingKey?: string
|
|
24
|
+
/**
|
|
25
|
+
* Modules that must be enabled for this action to be offered/executed.
|
|
26
|
+
* Mirrors `InjectionWidgetMetadata.requiredModules` — omit for actions
|
|
27
|
+
* owned by `configs` itself.
|
|
28
|
+
*/
|
|
29
|
+
requiredModules?: string[]
|
|
24
30
|
run: (ctx: UpgradeActionContext) => Promise<void>
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -119,6 +125,7 @@ export const upgradeActions: UpgradeActionDefinition[] = [
|
|
|
119
125
|
ctaKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.cta',
|
|
120
126
|
successKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.success',
|
|
121
127
|
loadingKey: 'payment_gateways.upgradeActions.sessionInitializationPrune.loading',
|
|
128
|
+
requiredModules: ['payment_gateways'],
|
|
122
129
|
run: async ({ container, tenantId, organizationId }) => {
|
|
123
130
|
const { registerSessionInitializationPruneSchedule } = await import(
|
|
124
131
|
'@open-mercato/core/modules/payment_gateways/setup'
|
|
@@ -3,6 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
|
|
|
3
3
|
import type { AwilixContainer } from 'awilix'
|
|
4
4
|
import { appVersion } from '@open-mercato/shared/lib/version'
|
|
5
5
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
6
|
+
import { getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
6
7
|
import { UpgradeActionRun } from '../data/entities'
|
|
7
8
|
import { actionsUpToVersion, findUpgradeAction, type UpgradeActionDefinition } from '../lib/upgrade-actions'
|
|
8
9
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -19,6 +20,18 @@ export function getCurrentVersion(): string {
|
|
|
19
20
|
return appVersion
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* An action with no `requiredModules` is owned by `configs` itself and is
|
|
25
|
+
* always available. Fails closed when the module registry is empty/unavailable,
|
|
26
|
+
* mirroring `isDocumentEntityRegistryModuleEnabled`.
|
|
27
|
+
*/
|
|
28
|
+
function isActionModuleEnabled(definition: UpgradeActionDefinition): boolean {
|
|
29
|
+
const required = definition.requiredModules
|
|
30
|
+
if (!Array.isArray(required) || required.length === 0) return true
|
|
31
|
+
const enabledModuleIds = new Set(getEnabledModuleIds())
|
|
32
|
+
return required.every((moduleId) => enabledModuleIds.has(moduleId))
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
export async function listPendingUpgradeActions(
|
|
23
36
|
em: EntityManager,
|
|
24
37
|
{
|
|
@@ -37,7 +50,9 @@ export async function listPendingUpgradeActions(
|
|
|
37
50
|
version: { $in: versions },
|
|
38
51
|
})
|
|
39
52
|
const completed = new Set(runs.map((run) => `${run.version}::${run.actionId}`))
|
|
40
|
-
return definitions
|
|
53
|
+
return definitions
|
|
54
|
+
.filter((definition) => !completed.has(`${definition.version}::${definition.id}`))
|
|
55
|
+
.filter(isActionModuleEnabled)
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
export async function executeUpgradeAction(
|
|
@@ -53,7 +68,7 @@ export async function executeUpgradeAction(
|
|
|
53
68
|
throw new Error('UPGRADE_ACTIONS_DISABLED')
|
|
54
69
|
}
|
|
55
70
|
const definition = findUpgradeAction(actionId, version)
|
|
56
|
-
if (!definition) {
|
|
71
|
+
if (!definition || !isActionModuleEnabled(definition)) {
|
|
57
72
|
throw new Error('UPGRADE_ACTION_NOT_AVAILABLE')
|
|
58
73
|
}
|
|
59
74
|
const em = container.resolve<EntityManager>('em')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type DefinitionScopeClauseInput = {
|
|
2
|
+
organizationId?: string | null
|
|
3
|
+
tenantId?: string | null
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// SQL `IN (?, NULL)` never matches rows where the column IS NULL, so a scope
|
|
7
|
+
// filter that mixes a concrete id with a literal null has to be expressed as an
|
|
8
|
+
// `$or` of equality and IS NULL. Every query that resolves the definitions
|
|
9
|
+
// visible to a scope — the write path, the validation path, the single-key
|
|
10
|
+
// lookups — routes through this builder so they cannot drift apart again (#5919).
|
|
11
|
+
export function createVisibleDefinitionScopeClause(scope: DefinitionScopeClauseInput) {
|
|
12
|
+
const organizationId = scope.organizationId ?? null
|
|
13
|
+
const tenantId = scope.tenantId ?? null
|
|
14
|
+
|
|
15
|
+
const organizationCandidates = [{ organizationId: null as string | null }]
|
|
16
|
+
if (organizationId !== null) organizationCandidates.unshift({ organizationId })
|
|
17
|
+
|
|
18
|
+
const tenantCandidates = [{ tenantId: null as string | null }]
|
|
19
|
+
if (tenantId !== null) tenantCandidates.unshift({ tenantId })
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
$and: [
|
|
23
|
+
{ $or: organizationCandidates },
|
|
24
|
+
{ $or: tenantCandidates },
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
3
3
|
import { CustomFieldDef } from '@open-mercato/core/modules/entities/data/entities'
|
|
4
|
+
import { createVisibleDefinitionScopeClause } from './definition-scope-where'
|
|
4
5
|
|
|
5
6
|
type AuthScope = {
|
|
6
7
|
tenantId?: string | null
|
|
@@ -84,20 +85,11 @@ export function createVisibleDefinitionWhere(
|
|
|
84
85
|
scope: DefinitionMutationScope,
|
|
85
86
|
options: DefinitionVisibilityOptions = {},
|
|
86
87
|
) {
|
|
87
|
-
const organizationCandidates = [{ organizationId: null as string | null }]
|
|
88
|
-
if (scope.organizationId) organizationCandidates.unshift({ organizationId: scope.organizationId })
|
|
89
|
-
|
|
90
|
-
const tenantCandidates = [{ tenantId: null as string | null }]
|
|
91
|
-
if (scope.tenantId) tenantCandidates.unshift({ tenantId: scope.tenantId })
|
|
92
|
-
|
|
93
88
|
return {
|
|
94
89
|
entityId,
|
|
95
90
|
key,
|
|
96
91
|
...options,
|
|
97
|
-
|
|
98
|
-
{ $or: organizationCandidates },
|
|
99
|
-
{ $or: tenantCandidates },
|
|
100
|
-
],
|
|
92
|
+
...createVisibleDefinitionScopeClause(scope),
|
|
101
93
|
}
|
|
102
94
|
}
|
|
103
95
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
TOO_MANY_CUSTOM_FIELDS_ERROR,
|
|
7
7
|
} from '@open-mercato/shared/modules/entities/validation'
|
|
8
8
|
import { CustomFieldDef, CustomFieldValue } from '../data/entities'
|
|
9
|
+
import { loadScopedCustomFieldDefs } from './scoped-field-defs'
|
|
9
10
|
|
|
10
11
|
type Primitive = string | number | boolean | null | undefined
|
|
11
12
|
type PrimitiveOrArray = Primitive | Primitive[]
|
|
@@ -70,39 +71,9 @@ export async function setRecordCustomFields(
|
|
|
70
71
|
const tenantId = opts.tenantId ?? null
|
|
71
72
|
const preferDefs = opts.preferDefs !== false
|
|
72
73
|
|
|
73
|
-
let defsByKey:
|
|
74
|
+
let defsByKey: Map<string, CustomFieldDef> | undefined
|
|
74
75
|
if (preferDefs) {
|
|
75
|
-
|
|
76
|
-
entityId,
|
|
77
|
-
isActive: true,
|
|
78
|
-
deletedAt: null,
|
|
79
|
-
organizationId: { $in: [organizationId, null] as any },
|
|
80
|
-
tenantId: { $in: [tenantId, null] as any },
|
|
81
|
-
})
|
|
82
|
-
const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
|
|
83
|
-
defsByKey = {}
|
|
84
|
-
for (const d of defs) {
|
|
85
|
-
const existing = defsByKey[d.key]
|
|
86
|
-
if (!existing) {
|
|
87
|
-
defsByKey[d.key] = d
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
const nextScore = scopeScore(d)
|
|
91
|
-
const existingScore = scopeScore(existing)
|
|
92
|
-
if (nextScore > existingScore) {
|
|
93
|
-
defsByKey[d.key] = d
|
|
94
|
-
continue
|
|
95
|
-
}
|
|
96
|
-
if (nextScore < existingScore) continue
|
|
97
|
-
|
|
98
|
-
const nextUpdatedAt = d.updatedAt instanceof Date ? d.updatedAt.getTime() : new Date(d.updatedAt).getTime()
|
|
99
|
-
const existingUpdatedAt = existing.updatedAt instanceof Date
|
|
100
|
-
? existing.updatedAt.getTime()
|
|
101
|
-
: new Date(existing.updatedAt).getTime()
|
|
102
|
-
if (nextUpdatedAt >= existingUpdatedAt) {
|
|
103
|
-
defsByKey[d.key] = d
|
|
104
|
-
}
|
|
105
|
-
}
|
|
76
|
+
defsByKey = await loadScopedCustomFieldDefs(em, { entityId, organizationId, tenantId })
|
|
106
77
|
}
|
|
107
78
|
|
|
108
79
|
const toPersist: CustomFieldValue[] = []
|
|
@@ -147,7 +118,7 @@ export async function setRecordCustomFields(
|
|
|
147
118
|
const raw = values[fieldKey]
|
|
148
119
|
if (raw === undefined) continue
|
|
149
120
|
|
|
150
|
-
const def = defsByKey?.
|
|
121
|
+
const def = defsByKey?.get(fieldKey)
|
|
151
122
|
const encrypted = Boolean(def?.configJson && (def as any).configJson?.encrypted)
|
|
152
123
|
const isArray = Array.isArray(raw)
|
|
153
124
|
// When array (multi-value): replace all existing rows for the key. Delete
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/core'
|
|
2
2
|
import { CustomFieldDef } from '../data/entities'
|
|
3
|
+
import { createVisibleDefinitionScopeClause } from './definition-scope-where'
|
|
3
4
|
|
|
4
5
|
export type ScopedCustomFieldDefsOptions = {
|
|
5
6
|
entityId: string
|
|
@@ -19,25 +20,15 @@ export async function loadScopedCustomFieldDefs(
|
|
|
19
20
|
em: EntityManager,
|
|
20
21
|
opts: ScopedCustomFieldDefsOptions,
|
|
21
22
|
): Promise<Map<string, CustomFieldDef>> {
|
|
22
|
-
const organizationId = opts.organizationId ?? null
|
|
23
|
-
const tenantId = opts.tenantId ?? null
|
|
24
23
|
const defs = await em.find(CustomFieldDef, {
|
|
25
24
|
entityId: opts.entityId,
|
|
26
25
|
isActive: true,
|
|
27
26
|
deletedAt: null,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
$or: tenantId === null
|
|
36
|
-
? [{ tenantId: null }]
|
|
37
|
-
: [{ tenantId }, { tenantId: null }],
|
|
38
|
-
},
|
|
39
|
-
],
|
|
40
|
-
} as any)
|
|
27
|
+
...createVisibleDefinitionScopeClause({
|
|
28
|
+
organizationId: opts.organizationId,
|
|
29
|
+
tenantId: opts.tenantId,
|
|
30
|
+
}),
|
|
31
|
+
})
|
|
41
32
|
|
|
42
33
|
const byKey = new Map<string, CustomFieldDef>()
|
|
43
34
|
for (const def of defs) {
|
|
@@ -528,10 +528,10 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
528
528
|
// `ignoreRuntimeHealth` asks the on-disk question -- a column holds ciphertext even while
|
|
529
529
|
// the KMS is down -- so an outage keeps encrypted columns on the token path (#4622).
|
|
530
530
|
// `organizationId: null` is deliberate, not an omission: the service then unions in every
|
|
531
|
-
// organization's map
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
531
|
+
// organization's map, so a field any org encrypts stays on the token path -- a wider set
|
|
532
|
+
// fails safe. Passing the request's org instead would silently break encrypted-column
|
|
533
|
+
// search for orgs without their own map. The service caches that union (#5949), and the
|
|
534
|
+
// TTL cache in `resolveEncryptedLikeFieldSet` keeps even the lookup off the request path.
|
|
535
535
|
try {
|
|
536
536
|
const encryptionService = this.getEncryptionService()
|
|
537
537
|
const readEncryptedFieldNames = encryptionService?.getEncryptedFieldNames?.bind(encryptionService)
|
|
@@ -168,6 +168,37 @@ function shouldIndexField(
|
|
|
168
168
|
return collectTextValues(value).some((text) => text.length > 0)
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Builds the `search_tokens` rows for one document.
|
|
173
|
+
*
|
|
174
|
+
* `maxTokensPerRecord` is spent in the iteration order of `params.doc`'s own keys: fields are
|
|
175
|
+
* tokenized one after another and the loop stops at the first field that exhausts the budget, so
|
|
176
|
+
* on an over-budget record the surviving fields are whichever ones come first. That order is a
|
|
177
|
+
* property of the object handed in, not of the entity, and the two write-path builders order it
|
|
178
|
+
* differently: `buildIndexDocument` (`lib/document.ts`, the batch reindex path) appends `cf:*` keys
|
|
179
|
+
* after the base columns and the aggregate `search_text` field last. `buildIndexDoc` (`lib/indexer.ts`,
|
|
180
|
+
* the incremental single-record write path — `upsertIndexRow` → `reindexSearchTokensForRecord`) adds
|
|
181
|
+
* `l10n:{locale}:{field}` translation keys between the `cf:*` keys and `search_text`. The budget is
|
|
182
|
+
* spent front-to-back, so the fields nearest the end of that order are starved first: `search_text`
|
|
183
|
+
* is last on both paths and starves first either way, but starves sooner on the incremental path,
|
|
184
|
+
* because the `l10n:*` keys ahead of it there consume budget the batch path would have spent on it.
|
|
185
|
+
* Those translation fields starve next, before any `cf:*` key or base column. The two paths can
|
|
186
|
+
* therefore keep different fields searchable for an otherwise equivalent record.
|
|
187
|
+
*
|
|
188
|
+
* Every write path tokenizes an in-memory document it is about to write — `TokenSearchStrategy.index`
|
|
189
|
+
* writes only `search_tokens` rows and never touches `entity_indexes.doc` — which keeps each path
|
|
190
|
+
* self-consistent on its own. A document read back out of the `entity_indexes.doc` `jsonb` column is
|
|
191
|
+
* not the same object: Postgres stores `jsonb` keys in a canonical order (by key length, then byte
|
|
192
|
+
* order), not in insertion order. Re-tokenizing such a document can therefore truncate at a different
|
|
193
|
+
* field than the write did, so code that verifies or recomputes a record's expected tokens must
|
|
194
|
+
* rebuild the document through the builder that matches the write path being checked — `buildIndexDoc`
|
|
195
|
+
* for incrementally-written records, `buildIndexDocument` for batch-reindexed ones — rather than
|
|
196
|
+
* reading it back from the database.
|
|
197
|
+
*
|
|
198
|
+
* Making truncation reproducible from a `jsonb` read — by sorting fields before tokenization —
|
|
199
|
+
* would change which terms stay searchable on over-budget records, so it is a behavior decision
|
|
200
|
+
* rather than a clarification (#5971).
|
|
201
|
+
*/
|
|
171
202
|
export function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {
|
|
172
203
|
const config = params.config ?? resolveSearchConfig()
|
|
173
204
|
if (!config.enabled) return []
|
|
@@ -141,6 +141,13 @@ export function AdjustInventoryDialog({
|
|
|
141
141
|
)
|
|
142
142
|
const [submitting, setSubmitting] = React.useState(false)
|
|
143
143
|
const [form, setForm] = React.useState<AdjustFormValues>(EMPTY_FORM)
|
|
144
|
+
// Raw text the user is typing into the delta field, decoupled from `form.delta` (issue
|
|
145
|
+
// #5828): re-deriving the input's display from the committed number on every keystroke
|
|
146
|
+
// drops an in-progress decimal separator the instant it's typed (`2,5` collapses to `2`
|
|
147
|
+
// then the next digit appends, producing `25`). Synced from `form.delta` only while the
|
|
148
|
+
// field is not focused, mirroring `CrudForm.NumberInput`'s buffer.
|
|
149
|
+
const [deltaInputText, setDeltaInputText] = React.useState<string>(String(EMPTY_FORM.delta))
|
|
150
|
+
const deltaInputFocusedRef = React.useRef(false)
|
|
144
151
|
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
|
|
145
152
|
const [onHand, setOnHand] = React.useState<number | null>(null)
|
|
146
153
|
const [previewError, setPreviewError] = React.useState<string | null>(null)
|
|
@@ -189,6 +196,7 @@ export function AdjustInventoryDialog({
|
|
|
189
196
|
|
|
190
197
|
const resetDialog = React.useCallback(() => {
|
|
191
198
|
setForm(EMPTY_FORM)
|
|
199
|
+
setDeltaInputText(String(EMPTY_FORM.delta))
|
|
192
200
|
setFieldErrors({})
|
|
193
201
|
setOnHand(null)
|
|
194
202
|
setPreviewError(null)
|
|
@@ -208,6 +216,10 @@ export function AdjustInventoryDialog({
|
|
|
208
216
|
setFieldErrors({})
|
|
209
217
|
}, [])
|
|
210
218
|
|
|
219
|
+
React.useEffect(() => {
|
|
220
|
+
if (!deltaInputFocusedRef.current) setDeltaInputText(String(form.delta))
|
|
221
|
+
}, [form.delta])
|
|
222
|
+
|
|
211
223
|
React.useEffect(() => {
|
|
212
224
|
if (!open) return
|
|
213
225
|
const catalogVariantId = initialCatalogVariantId?.trim()
|
|
@@ -689,12 +701,20 @@ export function AdjustInventoryDialog({
|
|
|
689
701
|
<Input
|
|
690
702
|
type="text"
|
|
691
703
|
inputMode="decimal"
|
|
692
|
-
value={
|
|
704
|
+
value={deltaInputText}
|
|
693
705
|
onChange={(event) => {
|
|
706
|
+
setDeltaInputText(event.target.value)
|
|
694
707
|
const parsed = parseDeltaInput(event.target.value, locale)
|
|
695
708
|
if (parsed == null) return
|
|
696
709
|
patchForm({ delta: parsed })
|
|
697
710
|
}}
|
|
711
|
+
onFocus={() => {
|
|
712
|
+
deltaInputFocusedRef.current = true
|
|
713
|
+
}}
|
|
714
|
+
onBlur={() => {
|
|
715
|
+
deltaInputFocusedRef.current = false
|
|
716
|
+
setDeltaInputText(String(form.delta))
|
|
717
|
+
}}
|
|
698
718
|
className="h-8 w-auto min-w-0 flex-1 border-0 bg-transparent p-0 shadow-none focus-visible:ring-0"
|
|
699
719
|
inputClassName="text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
700
720
|
disabled={submitting}
|