@open-mercato/core 0.8.1-develop.7206.1.45a3cee87f → 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.
Files changed (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +6 -2
  3. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  4. package/dist/modules/catalog/backend/catalog/products/create/page.js +7 -3
  5. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  6. package/dist/modules/catalog/components/products/ProductUomSection.js +11 -22
  7. package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
  8. package/dist/modules/catalog/components/products/productForm.js +26 -1
  9. package/dist/modules/catalog/components/products/productForm.js.map +2 -2
  10. package/dist/modules/configs/lib/upgrade-actions.js +1 -0
  11. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  12. package/dist/modules/configs/services/upgradeActionsService.js +9 -2
  13. package/dist/modules/configs/services/upgradeActionsService.js.map +2 -2
  14. package/dist/modules/entities/lib/definition-scope-where.js +18 -0
  15. package/dist/modules/entities/lib/definition-scope-where.js.map +7 -0
  16. package/dist/modules/entities/lib/definition-scope.js +2 -8
  17. package/dist/modules/entities/lib/definition-scope.js.map +2 -2
  18. package/dist/modules/entities/lib/helpers.js +4 -30
  19. package/dist/modules/entities/lib/helpers.js.map +2 -2
  20. package/dist/modules/entities/lib/scoped-field-defs.js +5 -10
  21. package/dist/modules/entities/lib/scoped-field-defs.js.map +2 -2
  22. package/dist/modules/query_index/lib/engine.js.map +1 -1
  23. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  24. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js +15 -1
  25. package/dist/modules/wms/components/backend/AdjustInventoryDialog.js.map +2 -2
  26. package/package.json +7 -7
  27. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +6 -2
  28. package/src/modules/catalog/backend/catalog/products/create/page.tsx +7 -6
  29. package/src/modules/catalog/components/products/ProductUomSection.tsx +11 -31
  30. package/src/modules/catalog/components/products/productForm.ts +53 -0
  31. package/src/modules/configs/lib/upgrade-actions.ts +7 -0
  32. package/src/modules/configs/services/upgradeActionsService.ts +17 -2
  33. package/src/modules/entities/lib/definition-scope-where.ts +27 -0
  34. package/src/modules/entities/lib/definition-scope.ts +2 -10
  35. package/src/modules/entities/lib/helpers.ts +4 -33
  36. package/src/modules/entities/lib/scoped-field-defs.ts +6 -15
  37. package/src/modules/query_index/lib/engine.ts +4 -4
  38. package/src/modules/query_index/lib/search-tokens.ts +31 -0
  39. 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
- toBaseFactor: toTrimmedOrNull(row.toBaseFactor)
104
- ? normalizeDecimalInput(toTrimmedOrNull(row.toBaseFactor) as string, locale)
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, locale),
154
- [values.unitConversions, locale],
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 defaultSalesQuantityRaw =
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 unitPriceBaseQuantityRaw =
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: normalizeDecimalInput(event.target.value, locale),
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.filter((definition) => !completed.has(`${definition.version}::${definition.id}`))
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
- $and: [
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: Record<string, CustomFieldDef> | undefined
74
+ let defsByKey: Map<string, CustomFieldDef> | undefined
74
75
  if (preferDefs) {
75
- const defs = await em.find(CustomFieldDef, {
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?.[fieldKey]
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
- $and: [
29
- {
30
- $or: organizationId === null
31
- ? [{ organizationId: null }]
32
- : [{ organizationId }, { organizationId: null }],
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 (`fetchAllOrganizationFieldNames`), so a field any org encrypts stays
532
- // on the token path -- a wider set fails safe. Passing the request's org instead would
533
- // silently break encrypted-column search for orgs without their own map. That union is an
534
- // UNCACHED `encryption_maps` read, one extra round-trip per searched list request.
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={String(form.delta)}
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}