@open-mercato/core 0.6.8-develop.6992.1.00c90fecff → 0.6.8-develop.6998.1.d2bc46c56c

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 (87) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/sync_run/index.js +2 -0
  3. package/dist/generated/entities/sync_run/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/auth/api/sidebar/preferences/route.js +2 -2
  7. package/dist/modules/auth/api/sidebar/preferences/route.js.map +1 -1
  8. package/dist/modules/auth/lib/backendChrome.js +3 -3
  9. package/dist/modules/auth/lib/backendChrome.js.map +1 -1
  10. package/dist/modules/auth/services/sidebarPreferencesService.js +7 -2
  11. package/dist/modules/auth/services/sidebarPreferencesService.js.map +2 -2
  12. package/dist/modules/data_sync/api/options.js +1 -0
  13. package/dist/modules/data_sync/api/options.js.map +2 -2
  14. package/dist/modules/data_sync/api/run.js +15 -1
  15. package/dist/modules/data_sync/api/run.js.map +2 -2
  16. package/dist/modules/data_sync/api/runs/[id]/retry.js +23 -1
  17. package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
  18. package/dist/modules/data_sync/api/runs/[id]/route.js +1 -0
  19. package/dist/modules/data_sync/api/runs/[id]/route.js.map +2 -2
  20. package/dist/modules/data_sync/backend/data-sync/page.js +60 -17
  21. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  22. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +34 -2
  23. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  24. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +21 -15
  25. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  26. package/dist/modules/data_sync/components/RunParameterFields.js +135 -0
  27. package/dist/modules/data_sync/components/RunParameterFields.js.map +7 -0
  28. package/dist/modules/data_sync/data/entities.js +3 -0
  29. package/dist/modules/data_sync/data/entities.js.map +2 -2
  30. package/dist/modules/data_sync/data/validators.js +4 -1
  31. package/dist/modules/data_sync/data/validators.js.map +2 -2
  32. package/dist/modules/data_sync/lib/run-parameters.js +108 -0
  33. package/dist/modules/data_sync/lib/run-parameters.js.map +7 -0
  34. package/dist/modules/data_sync/lib/start-run.js +1 -0
  35. package/dist/modules/data_sync/lib/start-run.js.map +2 -2
  36. package/dist/modules/data_sync/lib/sync-engine.js +4 -2
  37. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-run-service.js +1 -0
  39. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  40. package/dist/modules/data_sync/migrations/Migration20260810120000.js +13 -0
  41. package/dist/modules/data_sync/migrations/Migration20260810120000.js.map +7 -0
  42. package/dist/modules/data_sync/workers/sync-scheduled.js +23 -2
  43. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  44. package/dist/modules/query_index/di.js +125 -48
  45. package/dist/modules/query_index/di.js.map +2 -2
  46. package/dist/modules/sales/api/documents/factory.js +39 -1
  47. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  48. package/dist/modules/sales/commands/documents.js +26 -5
  49. package/dist/modules/sales/commands/documents.js.map +2 -2
  50. package/generated/entities/sync_run/index.ts +1 -0
  51. package/generated/entity-fields-registry.ts +1 -0
  52. package/package.json +7 -7
  53. package/src/modules/auth/api/sidebar/preferences/route.ts +2 -2
  54. package/src/modules/auth/lib/backendChrome.tsx +2 -2
  55. package/src/modules/auth/services/sidebarPreferencesService.ts +22 -3
  56. package/src/modules/data_sync/AGENTS.md +83 -8
  57. package/src/modules/data_sync/api/options.ts +1 -0
  58. package/src/modules/data_sync/api/run.ts +17 -0
  59. package/src/modules/data_sync/api/runs/[id]/retry.ts +32 -1
  60. package/src/modules/data_sync/api/runs/[id]/route.ts +1 -0
  61. package/src/modules/data_sync/backend/data-sync/page.tsx +73 -17
  62. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +62 -2
  63. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +28 -15
  64. package/src/modules/data_sync/components/RunParameterFields.tsx +229 -0
  65. package/src/modules/data_sync/data/entities.ts +4 -1
  66. package/src/modules/data_sync/data/validators.ts +3 -0
  67. package/src/modules/data_sync/i18n/de.json +10 -0
  68. package/src/modules/data_sync/i18n/en.json +10 -0
  69. package/src/modules/data_sync/i18n/es.json +10 -0
  70. package/src/modules/data_sync/i18n/ko.json +10 -0
  71. package/src/modules/data_sync/i18n/pl.json +10 -0
  72. package/src/modules/data_sync/lib/adapter.ts +88 -0
  73. package/src/modules/data_sync/lib/run-parameters.ts +196 -0
  74. package/src/modules/data_sync/lib/start-run.ts +2 -0
  75. package/src/modules/data_sync/lib/sync-engine.ts +5 -1
  76. package/src/modules/data_sync/lib/sync-run-service.ts +2 -0
  77. package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +17 -1
  78. package/src/modules/data_sync/migrations/Migration20260810120000.ts +13 -0
  79. package/src/modules/data_sync/workers/sync-scheduled.ts +32 -1
  80. package/src/modules/query_index/di.ts +153 -56
  81. package/src/modules/sales/api/documents/factory.ts +62 -1
  82. package/src/modules/sales/commands/documents.ts +40 -4
  83. package/src/modules/sales/i18n/de.json +1 -0
  84. package/src/modules/sales/i18n/en.json +1 -0
  85. package/src/modules/sales/i18n/es.json +1 -0
  86. package/src/modules/sales/i18n/ko.json +1 -0
  87. package/src/modules/sales/i18n/pl.json +1 -0
@@ -1,17 +1,113 @@
1
- import type { AppContainer } from '@open-mercato/shared/lib/di/container'
2
- import { BasicQueryEngine, resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
3
- import { HybridQueryEngine } from './lib/engine'
4
- import { markDeleted } from './lib/indexer'
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
5
2
  import type { EventBus } from '@open-mercato/events'
6
3
  import type { VectorIndexService } from '@open-mercato/search/vector'
4
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
7
5
  import { CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY } from '@open-mercato/shared/lib/crud/types'
6
+ import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
7
+ import { createLogger } from '@open-mercato/shared/lib/logger'
8
+ import { BasicQueryEngine } from '@open-mercato/shared/lib/query/engine'
9
+ import { HybridQueryEngine } from './lib/engine'
10
+ import {
11
+ loadQueryIndexRowScope,
12
+ QueryIndexScopeError,
13
+ resolveQueryIndexRecordScope,
14
+ resolveQueryIndexSourceMetadata,
15
+ } from './lib/subscriber-scope'
16
+
17
+ const logger = createLogger('query_index').child({ component: 'crud-bridge' })
18
+
19
+ function hasOwn(value: unknown, key: string): boolean {
20
+ return !!value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)
21
+ }
22
+
23
+ function readScopeValue(
24
+ source: unknown,
25
+ keys: string[],
26
+ ): { value: string | null | undefined; present: boolean } {
27
+ const sourceRecord = source && typeof source === 'object' ? source as Record<string, unknown> : {}
28
+ const key = keys.find((candidate) => hasOwn(source, candidate))
29
+ if (!key) return { value: undefined, present: false }
30
+ const value = sourceRecord[key]
31
+ if (value === null) return { value: null, present: true }
32
+ if (typeof value === 'string' && value.trim().length > 0) return { value, present: true }
33
+ return { value: undefined, present: false }
34
+ }
35
+
36
+ async function resolveBridgeRecordScope(
37
+ em: EntityManager,
38
+ entityType: string,
39
+ recordId: string,
40
+ payload: unknown,
41
+ action: 'upsert' | 'delete',
42
+ options?: { validateCompletePayload?: boolean },
43
+ ) {
44
+ let organization = readScopeValue(payload, ['organizationId', 'orgId'])
45
+ let tenant = readScopeValue(payload, ['tenantId'])
46
+ if (organization.present && tenant.present && options?.validateCompletePayload !== true) {
47
+ return {
48
+ organizationId: organization.value ?? null,
49
+ tenantId: tenant.value ?? null,
50
+ sourceValidated: false,
51
+ }
52
+ }
53
+ const source = resolveQueryIndexSourceMetadata(em, entityType)
54
+ const sourceScope = await loadQueryIndexRowScope(em, source, recordId)
55
+
56
+ if (sourceScope.kind === 'global') {
57
+ if (!organization.present) organization = { value: null, present: true }
58
+ if (!tenant.present) tenant = { value: null, present: true }
59
+ } else if (sourceScope.kind === 'missing' && action === 'upsert') {
60
+ throw new QueryIndexScopeError(
61
+ 'Query index upsert event source row scope could not be resolved',
62
+ )
63
+ }
8
64
 
9
- function toEntityTypeFromEvent(event: string): string | null {
10
- // Expect '<module>.<entity>.<action>'
11
- const parts = event.split('.')
12
- if (parts.length !== 3) return null
13
- const [mod, ent] = parts
14
- return `${mod}:${ent}`
65
+ const resolvedScope = resolveQueryIndexRecordScope({
66
+ payloadOrganizationId: organization.value,
67
+ payloadTenantId: tenant.value,
68
+ hasPayloadOrganizationId: organization.present,
69
+ hasPayloadTenantId: tenant.present,
70
+ sourceScope,
71
+ })
72
+ return { ...resolvedScope, sourceValidated: true }
73
+ }
74
+
75
+ async function recordBridgeError(input: {
76
+ em: EntityManager | null
77
+ handler: 'upsert' | 'delete'
78
+ error: unknown
79
+ entityType: string
80
+ recordId: string
81
+ payload: unknown
82
+ }): Promise<void> {
83
+ const handler = `event:query_index.crud_bridge.${input.handler}`
84
+ if (!input.em) {
85
+ logger.error('CRUD bridge event failed before the entity manager was resolved', {
86
+ handler,
87
+ entityType: input.entityType,
88
+ recordId: input.recordId,
89
+ err: input.error,
90
+ })
91
+ return
92
+ }
93
+ await recordIndexerError(
94
+ { em: input.em },
95
+ {
96
+ source: 'query_index',
97
+ handler,
98
+ error: input.error,
99
+ entityType: input.entityType,
100
+ recordId: input.recordId,
101
+ payload: input.payload,
102
+ },
103
+ ).catch((loggingError) => {
104
+ logger.error('Failed to record CRUD bridge error', {
105
+ handler,
106
+ entityType: input.entityType,
107
+ recordId: input.recordId,
108
+ err: loggingError,
109
+ })
110
+ })
15
111
  }
16
112
 
17
113
  export function register(container: AppContainer) {
@@ -65,30 +161,25 @@ export function register(container: AppContainer) {
65
161
  if (!bus) { setTimeout(setup, 0); return }
66
162
 
67
163
  const makeUpsertHandler = (entityType: string) => async (payload: any, ctx: any) => {
164
+ let em: EntityManager | null = null
165
+ let id = ''
68
166
  try {
69
167
  // DataEngine emits the canonical query_index.upsert_one itself. The
70
168
  // bridge only covers domain events from write paths that do not own an
71
169
  // indexer, otherwise failures and error logs are duplicated.
72
170
  if (payload?.[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY] === true) return
73
- const em = ctx.resolve('em')
74
- let orgId = payload?.organizationId || payload?.orgId || ctx?.organizationId || null
75
- let tenantId = payload?.tenantId || ctx?.tenantId || null
76
- const id = String(payload?.id || payload?.recordId || '')
171
+ em = ctx.resolve('em') as EntityManager
172
+ id = String(payload?.id || payload?.recordId || '')
77
173
  if (!id) return
78
- if (!orgId || !tenantId) {
79
- try {
80
- const db = (em as any).getKysely()
81
- const table = resolveEntityTableName(em, entityType)
82
- const row = await db
83
- .selectFrom(table as any)
84
- .select(['organization_id' as any, 'tenant_id' as any])
85
- .where('id' as any, '=', id)
86
- .executeTakeFirst() as { organization_id: string | null; tenant_id: string | null } | undefined
87
- orgId = row?.organization_id ?? orgId
88
- tenantId = row?.tenant_id ?? tenantId
89
- } catch {}
90
- }
174
+ const { organizationId: orgId, tenantId, sourceValidated } = await resolveBridgeRecordScope(
175
+ em,
176
+ entityType,
177
+ id,
178
+ payload,
179
+ 'upsert',
180
+ )
91
181
  // Optional: only index when custom field definitions exist for this entity (org/global)
182
+ let hasCustomFields: boolean | null = null
92
183
  try {
93
184
  const db = (em as any).getKysely()
94
185
  let cfQuery = db
@@ -112,41 +203,47 @@ export function register(container: AppContainer) {
112
203
  } else {
113
204
  cfQuery = cfQuery.where('tenant_id' as any, 'is', null as any)
114
205
  }
115
- const hasCf = await cfQuery.executeTakeFirst()
116
- if (!hasCf) return
206
+ hasCustomFields = !!await cfQuery.executeTakeFirst()
117
207
  } catch {}
118
- try {
119
- const bus = ctx.resolve('eventBus') as any
120
- await bus.emitEvent('query_index.upsert_one', { entityType, recordId: id, organizationId: orgId, tenantId })
121
- } catch {}
122
- } catch {}
208
+ if (hasCustomFields === false) {
209
+ if (!sourceValidated) {
210
+ await resolveBridgeRecordScope(
211
+ em,
212
+ entityType,
213
+ id,
214
+ payload,
215
+ 'upsert',
216
+ { validateCompletePayload: true },
217
+ )
218
+ }
219
+ return
220
+ }
221
+ const bus = ctx.resolve('eventBus') as any
222
+ await bus.emitEvent('query_index.upsert_one', { entityType, recordId: id, organizationId: orgId, tenantId })
223
+ } catch (error) {
224
+ await recordBridgeError({ em, handler: 'upsert', error, entityType, recordId: id, payload })
225
+ }
123
226
  }
124
227
  const makeDeleteHandler = (entityType: string) => async (payload: any, ctx: any) => {
228
+ let em: EntityManager | null = null
229
+ let id = ''
125
230
  try {
126
231
  if (payload?.[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY] === true) return
127
- const em = ctx.resolve('em')
128
- let orgId = payload?.organizationId || payload?.orgId || ctx?.organizationId || null
129
- let tenantId = payload?.tenantId || ctx?.tenantId || null
130
- const id = String(payload?.id || payload?.recordId || '')
232
+ em = ctx.resolve('em') as EntityManager
233
+ id = String(payload?.id || payload?.recordId || '')
131
234
  if (!id) return
132
- if (!orgId || !tenantId) {
133
- try {
134
- const db = (em as any).getKysely()
135
- const table = resolveEntityTableName(em, entityType)
136
- const row = await db
137
- .selectFrom(table as any)
138
- .select(['organization_id' as any, 'tenant_id' as any])
139
- .where('id' as any, '=', id)
140
- .executeTakeFirst() as { organization_id: string | null; tenant_id: string | null } | undefined
141
- orgId = row?.organization_id ?? orgId
142
- tenantId = row?.tenant_id ?? tenantId
143
- } catch {}
144
- }
145
- try {
146
- const bus = ctx.resolve('eventBus') as any
147
- await bus.emitEvent('query_index.delete_one', { entityType, recordId: id, organizationId: orgId, tenantId })
148
- } catch {}
149
- } catch {}
235
+ const { organizationId: orgId, tenantId } = await resolveBridgeRecordScope(
236
+ em,
237
+ entityType,
238
+ id,
239
+ payload,
240
+ 'delete',
241
+ )
242
+ const bus = ctx.resolve('eventBus') as any
243
+ await bus.emitEvent('query_index.delete_one', { entityType, recordId: id, organizationId: orgId, tenantId })
244
+ } catch (error) {
245
+ await recordBridgeError({ em, handler: 'delete', error, entityType, recordId: id, payload })
246
+ }
150
247
  }
151
248
 
152
249
  // Build list of entity ids to subscribe to
@@ -4,8 +4,10 @@ import { splitCustomFieldPayload, extractAllCustomFieldEntries } from '@open-mer
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
5
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
6
  import { E } from '#generated/entities.ids.generated'
7
+ import type { EntityManager } from '@mikro-orm/postgresql'
7
8
  import type { SalesOrder, SalesQuote } from '../../data/entities'
8
- import { SalesDocumentTagAssignment } from '../../data/entities'
9
+ import { SalesChannel, SalesDocumentTagAssignment } from '../../data/entities'
10
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
11
  import {
10
12
  ORDER_PAYMENT_LEDGER_FIELDS,
11
13
  ORDER_PAYMENT_LEDGER_WARNING_CODE,
@@ -298,6 +300,62 @@ const attachTags = async (payload: any, ctx: any) => {
298
300
  })
299
301
  }
300
302
 
303
+ // Liveness note: this hook runs BEFORE the CRUD list cache stores the payload, so `channelName`
304
+ // and `channelCode` are embedded in the cached entry. What keeps them fresh is that the hook also
305
+ // runs on the cache-HIT path (shared/lib/crud/factory.ts:1683) and reassigns both fields
306
+ // unconditionally for every item carrying a channel id. Anything that later skips this hook on a
307
+ // hit — the way `skipEnrichersOnCacheHit` does for record-pure enrichers — would start serving the
308
+ // stale names baked into the entry.
309
+ export const attachChannelNames = async (
310
+ payload: { items?: Array<Record<string, unknown>> },
311
+ ctx: CrudCtx,
312
+ ) => {
313
+ const items = Array.isArray(payload?.items) ? payload.items : []
314
+ if (!items.length) return
315
+ const channelIds = Array.from(
316
+ new Set(
317
+ items
318
+ .map((item) => (item && typeof item.channelId === 'string' ? item.channelId : null))
319
+ .filter((value): value is string => !!value)
320
+ )
321
+ )
322
+ if (!channelIds.length) return
323
+ const em = ctx?.container?.resolve ? (ctx.container.resolve('em') as EntityManager) : null
324
+ if (!em) return
325
+
326
+ const where: Record<string, unknown> = { id: { $in: channelIds } }
327
+ if (ctx?.auth?.tenantId) where.tenantId = ctx.auth.tenantId
328
+ const orgIds =
329
+ Array.isArray(ctx?.organizationIds) && ctx.organizationIds.length
330
+ ? ctx.organizationIds.filter((val: string | null) => !!val)
331
+ : ctx?.selectedOrganizationId
332
+ ? [ctx.selectedOrganizationId]
333
+ : []
334
+ if (orgIds.length) where.organizationId = { $in: orgIds }
335
+
336
+ // Only `name` and `code` are read. Without this projection the query loads the whole channel row,
337
+ // and `sales/encryption.ts` declares eight of its columns encrypted (contact email/phone, the
338
+ // address block) — so every documents-list request would decrypt PII it never renders, on the
339
+ // cache-hit path too.
340
+ const channels = await findWithDecryption(
341
+ em,
342
+ SalesChannel,
343
+ where,
344
+ { fields: ['id', 'name', 'code'] },
345
+ {
346
+ tenantId: ctx?.auth?.tenantId ?? null,
347
+ organizationId: ctx?.selectedOrganizationId ?? ctx?.auth?.orgId ?? null,
348
+ }
349
+ )
350
+ const byId = new Map(channels.map((channel) => [channel.id, channel]))
351
+ items.forEach((item) => {
352
+ if (!item || typeof item.channelId !== 'string') return
353
+ const channel = byId.get(item.channelId)
354
+ item.channelName = channel?.name ?? null
355
+ item.channelCode = channel?.code ?? null
356
+ })
357
+ }
358
+
301
359
  async function ensureNumberEditPermission(
302
360
  ctx: CrudCtx,
303
361
  translate: (key: string, fallback?: string) => string
@@ -557,6 +615,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
557
615
  hooks: {
558
616
  afterList: async (payload: any, ctx: CrudCtx) => {
559
617
  await attachTags(payload, { ...ctx, bindingKind: binding.kind })
618
+ await attachChannelNames(payload, ctx)
560
619
  if (binding.kind === 'order' && Array.isArray(payload?.items) && payload.items.length === 1) {
561
620
  const item = payload.items[0] as Record<string, unknown>
562
621
  const orderId = typeof item?.id === 'string' ? item.id : null
@@ -629,6 +688,8 @@ export function buildDocumentOpenApi(binding: DocumentBinding) {
629
688
  paymentMethodSnapshot: z.record(z.string(), z.unknown()).nullable().optional(),
630
689
  currencyCode: z.string().nullable(),
631
690
  channelId: z.string().uuid().nullable(),
691
+ channelName: z.string().nullable().optional(),
692
+ channelCode: z.string().nullable().optional(),
632
693
  organizationId: z.string().uuid().nullable(),
633
694
  tenantId: z.string().uuid().nullable(),
634
695
  validFrom: z.string().nullable().optional(),
@@ -594,6 +594,11 @@ export const documentUpdateSchema = z
594
594
  customerReference: z.string().nullable().optional(),
595
595
  externalReference: z.string().nullable().optional(),
596
596
  comment: z.string().nullable().optional(),
597
+ // Same names and limits as the create schema (data/validators.ts), so a
598
+ // caller can send one payload shape to create and update. Nullable here
599
+ // because an update — unlike a create — has an existing value to clear.
600
+ comments: z.string().trim().max(4000).nullable().optional(),
601
+ internalNotes: z.string().trim().max(4000).nullable().optional(),
597
602
  orderNumber: z.string().trim().min(1).max(191).optional(),
598
603
  quoteNumber: z.string().trim().min(1).max(191).optional(),
599
604
  currencyCode: currencyCodeSchema.optional(),
@@ -637,6 +642,8 @@ export const documentUpdateSchema = z
637
642
  input.customerReference !== undefined ||
638
643
  input.externalReference !== undefined ||
639
644
  input.comment !== undefined ||
645
+ input.comments !== undefined ||
646
+ input.internalNotes !== undefined ||
640
647
  input.orderNumber !== undefined ||
641
648
  input.quoteNumber !== undefined ||
642
649
  input.shippingAddressSnapshot !== undefined ||
@@ -1162,6 +1169,32 @@ async function applyDocumentUpdate({
1162
1169
  typeof input.comment === "string" ? input.comment.trim() : "";
1163
1170
  entity.comments = normalized.length ? normalized : null;
1164
1171
  }
1172
+ // After `comment`, so the canonical name wins when a payload carries both.
1173
+ if (input.comments !== undefined) {
1174
+ const normalized =
1175
+ typeof input.comments === "string" ? input.comments.trim() : "";
1176
+ entity.comments = normalized.length ? normalized : null;
1177
+ }
1178
+ // Orders only — SalesQuote has no internalNotes column. Reject rather than
1179
+ // ignore it on a quote: the field now satisfies the schema's refine on its
1180
+ // own, so silently dropping it would let an otherwise-empty payload run the
1181
+ // update, and a quote in `sent` status loses its acceptance token and reverts
1182
+ // to draft on any update that reaches execution.
1183
+ if (kind === "quote" && input.internalNotes !== undefined) {
1184
+ throw new CrudHttpError(400, {
1185
+ error: translate(
1186
+ "sales.quotes.internal_notes_unsupported",
1187
+ "Internal notes are not available on quotes.",
1188
+ ),
1189
+ });
1190
+ }
1191
+ if (kind === "order" && input.internalNotes !== undefined) {
1192
+ const normalized =
1193
+ typeof input.internalNotes === "string" ? input.internalNotes.trim() : "";
1194
+ (entity as SalesOrder).internalNotes = normalized.length
1195
+ ? normalized
1196
+ : null;
1197
+ }
1165
1198
  if (typeof input.currencyCode === "string") {
1166
1199
  entity.currencyCode = input.currencyCode;
1167
1200
  }
@@ -3692,9 +3725,11 @@ function buildDocumentUpdateChangeKeys(kind: SalesDocumentKind, input: DocumentU
3692
3725
  if (input.customerSnapshot !== undefined) keys.add("customerSnapshot");
3693
3726
  if (input.metadata !== undefined) keys.add("metadata");
3694
3727
  if (input.comment !== undefined) keys.add("comments");
3728
+ if (input.comments !== undefined) keys.add("comments");
3695
3729
  if (input.currencyCode !== undefined) keys.add("currencyCode");
3696
3730
  if (input.channelId !== undefined) keys.add("channelId");
3697
3731
  if (kind === "order") {
3732
+ if (input.internalNotes !== undefined) keys.add("internalNotes");
3698
3733
  if (input.placedAt !== undefined) keys.add("placedAt");
3699
3734
  if (input.expectedDeliveryAt !== undefined) keys.add("expectedDeliveryAt");
3700
3735
  }
@@ -5159,10 +5194,6 @@ const updateQuoteCommand: CommandHandler<
5159
5194
  ensureQuoteScope(ctx, quote.organizationId, quote.tenantId);
5160
5195
  await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
5161
5196
  const shouldInvalidateSentToken = (quote.status ?? null) === "sent";
5162
- if (shouldInvalidateSentToken) {
5163
- quote.acceptanceToken = null;
5164
- quote.sentAt = null;
5165
- }
5166
5197
  const shouldRecalculateTotals =
5167
5198
  parsed.shippingMethodId !== undefined ||
5168
5199
  parsed.shippingMethodSnapshot !== undefined ||
@@ -5185,7 +5216,12 @@ const updateQuoteCommand: CommandHandler<
5185
5216
  input: parsed,
5186
5217
  em,
5187
5218
  });
5219
+ // After applyDocumentUpdate, not before it: the reset must not happen
5220
+ // for an update that turns out to be invalid, or a rejected payload
5221
+ // still strips a sent quote of its acceptance link.
5188
5222
  if (shouldInvalidateSentToken) {
5223
+ quote.acceptanceToken = null;
5224
+ quote.sentAt = null;
5189
5225
  quote.status = "draft";
5190
5226
  quote.statusEntryId = await resolveStatusEntryIdByValue(em, {
5191
5227
  tenantId: quote.tenantId,
@@ -1287,6 +1287,7 @@
1287
1287
  "sales.quotes.form.general": "Allgemein",
1288
1288
  "sales.quotes.form.lines": "Positionen",
1289
1289
  "sales.quotes.form.notes": "Notizen",
1290
+ "sales.quotes.internal_notes_unsupported": "Interne Notizen sind für Angebote nicht verfügbar.",
1290
1291
  "sales.quotes.lines.delete": "Angebotsposition löschen",
1291
1292
  "sales.quotes.lines.upsert": "Angebotsposition aktualisieren",
1292
1293
  "sales.quotes.list.title": "Angebote",
@@ -1287,6 +1287,7 @@
1287
1287
  "sales.quotes.form.general": "General",
1288
1288
  "sales.quotes.form.lines": "Line Items",
1289
1289
  "sales.quotes.form.notes": "Notes",
1290
+ "sales.quotes.internal_notes_unsupported": "Internal notes are not available on quotes.",
1290
1291
  "sales.quotes.lines.delete": "Delete quote line",
1291
1292
  "sales.quotes.lines.upsert": "Update quote line",
1292
1293
  "sales.quotes.list.title": "Quotes",
@@ -1287,6 +1287,7 @@
1287
1287
  "sales.quotes.form.general": "General",
1288
1288
  "sales.quotes.form.lines": "Líneas",
1289
1289
  "sales.quotes.form.notes": "Notas",
1290
+ "sales.quotes.internal_notes_unsupported": "Las notas internas no están disponibles en los presupuestos.",
1290
1291
  "sales.quotes.lines.delete": "Eliminar línea de cotización",
1291
1292
  "sales.quotes.lines.upsert": "Actualizar línea de cotización",
1292
1293
  "sales.quotes.list.title": "Cotizaciones",
@@ -1287,6 +1287,7 @@
1287
1287
  "sales.quotes.form.general": "일반",
1288
1288
  "sales.quotes.form.lines": "라인 항목",
1289
1289
  "sales.quotes.form.notes": "메모",
1290
+ "sales.quotes.internal_notes_unsupported": "내부 메모는 견적서에서 사용할 수 없습니다.",
1290
1291
  "sales.quotes.lines.delete": "견적 항목 삭제",
1291
1292
  "sales.quotes.lines.upsert": "견적 항목 업데이트",
1292
1293
  "sales.quotes.list.title": "견적",
@@ -1287,6 +1287,7 @@
1287
1287
  "sales.quotes.form.general": "Ogólne",
1288
1288
  "sales.quotes.form.lines": "Pozycje",
1289
1289
  "sales.quotes.form.notes": "Notatki",
1290
+ "sales.quotes.internal_notes_unsupported": "Notatki wewnętrzne nie są dostępne dla ofert.",
1290
1291
  "sales.quotes.lines.delete": "Usuń pozycję oferty",
1291
1292
  "sales.quotes.lines.upsert": "Zaktualizuj pozycję oferty",
1292
1293
  "sales.quotes.list.title": "Oferty",