@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/sync_run/index.js +2 -0
- package/dist/generated/entities/sync_run/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/auth/api/sidebar/preferences/route.js +2 -2
- package/dist/modules/auth/api/sidebar/preferences/route.js.map +1 -1
- package/dist/modules/auth/lib/backendChrome.js +3 -3
- package/dist/modules/auth/lib/backendChrome.js.map +1 -1
- package/dist/modules/auth/services/sidebarPreferencesService.js +7 -2
- package/dist/modules/auth/services/sidebarPreferencesService.js.map +2 -2
- package/dist/modules/data_sync/api/options.js +1 -0
- package/dist/modules/data_sync/api/options.js.map +2 -2
- package/dist/modules/data_sync/api/run.js +15 -1
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/retry.js +23 -1
- package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/route.js +1 -0
- package/dist/modules/data_sync/api/runs/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +60 -17
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +34 -2
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +21 -15
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/data_sync/components/RunParameterFields.js +135 -0
- package/dist/modules/data_sync/components/RunParameterFields.js.map +7 -0
- package/dist/modules/data_sync/data/entities.js +3 -0
- package/dist/modules/data_sync/data/entities.js.map +2 -2
- package/dist/modules/data_sync/data/validators.js +4 -1
- package/dist/modules/data_sync/data/validators.js.map +2 -2
- package/dist/modules/data_sync/lib/run-parameters.js +108 -0
- package/dist/modules/data_sync/lib/run-parameters.js.map +7 -0
- package/dist/modules/data_sync/lib/start-run.js +1 -0
- package/dist/modules/data_sync/lib/start-run.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +4 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-run-service.js +1 -0
- package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
- package/dist/modules/data_sync/migrations/Migration20260810120000.js +13 -0
- package/dist/modules/data_sync/migrations/Migration20260810120000.js.map +7 -0
- package/dist/modules/data_sync/workers/sync-scheduled.js +23 -2
- package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
- package/dist/modules/query_index/di.js +125 -48
- package/dist/modules/query_index/di.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +39 -1
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +26 -5
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/generated/entities/sync_run/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/auth/api/sidebar/preferences/route.ts +2 -2
- package/src/modules/auth/lib/backendChrome.tsx +2 -2
- package/src/modules/auth/services/sidebarPreferencesService.ts +22 -3
- package/src/modules/data_sync/AGENTS.md +83 -8
- package/src/modules/data_sync/api/options.ts +1 -0
- package/src/modules/data_sync/api/run.ts +17 -0
- package/src/modules/data_sync/api/runs/[id]/retry.ts +32 -1
- package/src/modules/data_sync/api/runs/[id]/route.ts +1 -0
- package/src/modules/data_sync/backend/data-sync/page.tsx +73 -17
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +62 -2
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +28 -15
- package/src/modules/data_sync/components/RunParameterFields.tsx +229 -0
- package/src/modules/data_sync/data/entities.ts +4 -1
- package/src/modules/data_sync/data/validators.ts +3 -0
- package/src/modules/data_sync/i18n/de.json +10 -0
- package/src/modules/data_sync/i18n/en.json +10 -0
- package/src/modules/data_sync/i18n/es.json +10 -0
- package/src/modules/data_sync/i18n/ko.json +10 -0
- package/src/modules/data_sync/i18n/pl.json +10 -0
- package/src/modules/data_sync/lib/adapter.ts +88 -0
- package/src/modules/data_sync/lib/run-parameters.ts +196 -0
- package/src/modules/data_sync/lib/start-run.ts +2 -0
- package/src/modules/data_sync/lib/sync-engine.ts +5 -1
- package/src/modules/data_sync/lib/sync-run-service.ts +2 -0
- package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +17 -1
- package/src/modules/data_sync/migrations/Migration20260810120000.ts +13 -0
- package/src/modules/data_sync/workers/sync-scheduled.ts +32 -1
- package/src/modules/query_index/di.ts +153 -56
- package/src/modules/sales/api/documents/factory.ts +62 -1
- package/src/modules/sales/commands/documents.ts +40 -4
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/ko.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
|
@@ -1,17 +1,113 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
116
|
-
if (!hasCf) return
|
|
206
|
+
hasCustomFields = !!await cfQuery.executeTakeFirst()
|
|
117
207
|
} catch {}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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",
|