@open-mercato/core 0.6.8-develop.7031.1.005201cd70 → 0.6.8-develop.7037.1.ea0277b01e
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/business_rules/lib/expression-evaluator.js +9 -9
- package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
- package/dist/modules/business_rules/lib/rule-evaluator.js +10 -9
- package/dist/modules/business_rules/lib/rule-evaluator.js.map +2 -2
- package/dist/modules/customers/ai-tools/deal-analyzer-pack.js +13 -2
- package/dist/modules/customers/ai-tools/deal-analyzer-pack.js.map +2 -2
- package/dist/modules/wms/api/zones/route.js +15 -2
- package/dist/modules/wms/api/zones/route.js.map +2 -2
- package/dist/modules/wms/commands/configuration.js +64 -6
- package/dist/modules/wms/commands/configuration.js.map +2 -2
- package/dist/modules/wms/commands/shared.js +5 -1
- package/dist/modules/wms/commands/shared.js.map +2 -2
- package/dist/modules/wms/components/backend/WmsConfigurationPage.js +43 -8
- package/dist/modules/wms/components/backend/WmsConfigurationPage.js.map +3 -3
- package/package.json +7 -7
- package/src/modules/business_rules/lib/expression-evaluator.ts +9 -9
- package/src/modules/business_rules/lib/rule-evaluator.ts +10 -9
- package/src/modules/customers/ai-tools/deal-analyzer-pack.ts +17 -2
- package/src/modules/wms/api/zones/route.ts +16 -0
- package/src/modules/wms/commands/configuration.ts +89 -6
- package/src/modules/wms/commands/shared.ts +8 -0
- package/src/modules/wms/components/backend/WmsConfigurationPage.tsx +65 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7037.1.ea0277b01e",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7037.1.ea0277b01e",
|
|
256
|
+
"@open-mercato/shared": "0.6.8-develop.7037.1.ea0277b01e",
|
|
257
|
+
"@open-mercato/ui": "0.6.8-develop.7037.1.ea0277b01e",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
263
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
264
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7037.1.ea0277b01e",
|
|
263
|
+
"@open-mercato/shared": "0.6.8-develop.7037.1.ea0277b01e",
|
|
264
|
+
"@open-mercato/ui": "0.6.8-develop.7037.1.ea0277b01e",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ComparisonOperator, LogicalOperator } from '../data/validators'
|
|
2
2
|
import { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'
|
|
3
3
|
import { getNestedValue, resolveSpecialValue } from './value-resolver'
|
|
4
|
-
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
import { createLogger, isLevelEnabled } from '@open-mercato/shared/lib/logger'
|
|
5
5
|
|
|
6
6
|
const logger = createLogger('business_rules').child({ component: 'expression-evaluator' })
|
|
7
7
|
|
|
@@ -86,14 +86,14 @@ function evaluateSimpleCondition(
|
|
|
86
86
|
|
|
87
87
|
const result = applyOperator(leftValue, condition.operator, rightValue)
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
89
|
+
if (isLevelEnabled('debug')) {
|
|
90
|
+
logger.debug('Simple condition evaluated', {
|
|
91
|
+
field: condition.field,
|
|
92
|
+
operator: condition.operator,
|
|
93
|
+
actualValueType: typeof leftValue,
|
|
94
|
+
passed: result,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
97
|
|
|
98
98
|
return result
|
|
99
99
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BusinessRule } from '../data/entities'
|
|
2
2
|
import { evaluateExpression, type EvaluationContext, type ConditionExpression } from './expression-evaluator'
|
|
3
|
-
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
|
+
import { createLogger, isLevelEnabled } from '@open-mercato/shared/lib/logger'
|
|
4
4
|
|
|
5
5
|
const logger = createLogger('business_rules').child({ component: 'rule-evaluator' })
|
|
6
6
|
|
|
@@ -105,14 +105,15 @@ export async function evaluateSingleRule(
|
|
|
105
105
|
): Promise<SingleRuleResult> {
|
|
106
106
|
const startTime = Date.now()
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
if (isLevelEnabled('debug')) {
|
|
109
|
+
logger.debug('Evaluating rule', {
|
|
110
|
+
ruleId: rule.ruleId,
|
|
111
|
+
ruleName: rule.ruleName,
|
|
112
|
+
ruleType: rule.ruleType,
|
|
113
|
+
entityType: rule.entityType,
|
|
114
|
+
eventType: rule.eventType,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
116
117
|
|
|
117
118
|
try {
|
|
118
119
|
// Check if rule is enabled
|
|
@@ -192,14 +192,29 @@ const analyzeDealsToolDefinition: CustomersAiToolDefinition<AnalyzeDealsInput, A
|
|
|
192
192
|
// has no encrypted columns, but the linked CustomerEntity.display_name is
|
|
193
193
|
// encrypted, so resolve names via findWithDecryption rather than
|
|
194
194
|
// populating the relation through raw em.find.
|
|
195
|
+
// Defense-in-depth: `customer_deal_people` carries no tenant/org columns,
|
|
196
|
+
// so DB-level scoping is via the tenant-filtered `dealIds` set. Filter the
|
|
197
|
+
// returned rows against the caller-scoped deals map so a future refactor
|
|
198
|
+
// that widens `dealIds` cannot surface cross-tenant links.
|
|
199
|
+
const dealById = new Map<string, CustomerDeal>(deals.map((deal) => [deal.id, deal]))
|
|
200
|
+
const scopedDealIds = Array.from(dealById.keys())
|
|
195
201
|
const personLinkWhere: Record<string, unknown> = {
|
|
196
|
-
deal: { $in:
|
|
202
|
+
deal: { $in: scopedDealIds },
|
|
197
203
|
}
|
|
198
|
-
const
|
|
204
|
+
const personLinksRaw = await em.find(
|
|
199
205
|
CustomerDealPersonLink,
|
|
200
206
|
personLinkWhere as any,
|
|
201
207
|
{ limit: 500 },
|
|
202
208
|
)
|
|
209
|
+
const personLinks = personLinksRaw.filter((link) => {
|
|
210
|
+
const dealRefId = refIdOf((link as { deal?: unknown }).deal)
|
|
211
|
+
if (!dealRefId) return false
|
|
212
|
+
const scopedDeal = dealById.get(dealRefId)
|
|
213
|
+
if (!scopedDeal) return false
|
|
214
|
+
if (scopedDeal.tenantId !== tenantId) return false
|
|
215
|
+
if (ctx.organizationId && scopedDeal.organizationId !== ctx.organizationId) return false
|
|
216
|
+
return true
|
|
217
|
+
})
|
|
203
218
|
|
|
204
219
|
const linksByDeal = new Map<string, string>() // dealId → personId (first link wins)
|
|
205
220
|
for (const link of personLinks) {
|
|
@@ -79,6 +79,10 @@ const crud = makeCrudRoute({
|
|
|
79
79
|
}
|
|
80
80
|
return filters
|
|
81
81
|
},
|
|
82
|
+
// Zones had no decorator before this route gained custom fields, so there is no
|
|
83
|
+
// legacy consumer reading top-level `cf_*`/`cf:*` — take the canonical single-shape
|
|
84
|
+
// response (#1769) rather than emitting both forms.
|
|
85
|
+
decorateCustomFields: { entityIds: [E.wms.warehouse_zone], stripPrefixedKeys: true },
|
|
82
86
|
},
|
|
83
87
|
hooks: {
|
|
84
88
|
afterList: async (payload, ctx) => {
|
|
@@ -122,6 +126,16 @@ export const POST = crud.POST
|
|
|
122
126
|
export const PUT = crud.PUT
|
|
123
127
|
export const DELETE = crud.DELETE
|
|
124
128
|
|
|
129
|
+
// Mirrors `CustomFieldDisplayEntry` from @open-mercato/shared/lib/crud/custom-fields, so
|
|
130
|
+
// the generated OpenAPI documents how to read an entry instead of an opaque record.
|
|
131
|
+
const customFieldDisplayEntrySchema = z.object({
|
|
132
|
+
key: z.string(),
|
|
133
|
+
label: z.string().nullable(),
|
|
134
|
+
value: z.unknown(),
|
|
135
|
+
kind: z.string().nullable(),
|
|
136
|
+
multi: z.boolean(),
|
|
137
|
+
})
|
|
138
|
+
|
|
125
139
|
const zoneListItemSchema = z.object({
|
|
126
140
|
id: z.string().uuid().nullable().optional(),
|
|
127
141
|
organization_id: z.string().uuid().nullable().optional(),
|
|
@@ -134,6 +148,8 @@ const zoneListItemSchema = z.object({
|
|
|
134
148
|
priority: z.number().nullable().optional(),
|
|
135
149
|
created_at: z.string().nullable().optional(),
|
|
136
150
|
updated_at: z.string().nullable().optional(),
|
|
151
|
+
customValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
152
|
+
customFields: z.array(customFieldDisplayEntrySchema).optional(),
|
|
137
153
|
})
|
|
138
154
|
|
|
139
155
|
export const openApi = createWmsCrudOpenApi({
|
|
@@ -33,11 +33,20 @@ import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
|
33
33
|
import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
34
34
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
35
35
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
36
|
+
import {
|
|
37
|
+
emitCrudSideEffects,
|
|
38
|
+
emitCrudUndoSideEffects,
|
|
39
|
+
parseWithCustomFields,
|
|
40
|
+
setCustomFieldsIfAny,
|
|
41
|
+
} from '@open-mercato/shared/lib/commands/helpers'
|
|
42
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
43
|
+
import { buildCustomFieldResetMap, loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
|
|
36
44
|
import { CrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
|
|
37
45
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
38
46
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
39
47
|
import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo'
|
|
40
48
|
import type { JsonValue } from '@open-mercato/shared/lib/json'
|
|
49
|
+
import { E } from '#generated/entities.ids.generated'
|
|
41
50
|
import {
|
|
42
51
|
InventoryLot,
|
|
43
52
|
type InventoryLotStatus,
|
|
@@ -76,6 +85,7 @@ import {
|
|
|
76
85
|
normalizeOptionalString,
|
|
77
86
|
requireId,
|
|
78
87
|
toNumericString,
|
|
88
|
+
warehouseZoneCrudIndexer,
|
|
79
89
|
} from './shared'
|
|
80
90
|
import { emitWmsEvent } from '../events'
|
|
81
91
|
|
|
@@ -196,6 +206,7 @@ type WarehouseZoneSnapshot = {
|
|
|
196
206
|
name: string
|
|
197
207
|
priority: number
|
|
198
208
|
metadata: JsonValue | null
|
|
209
|
+
custom?: Record<string, unknown>
|
|
199
210
|
createdAt: string
|
|
200
211
|
updatedAt: string
|
|
201
212
|
}
|
|
@@ -331,7 +342,67 @@ async function loadWarehouseZoneSnapshot(
|
|
|
331
342
|
id: string,
|
|
332
343
|
): Promise<WarehouseZoneSnapshot | null> {
|
|
333
344
|
const record = await findOneWithDecryption(em, WarehouseZone, { id }, undefined, resolveScope(ctx))
|
|
334
|
-
|
|
345
|
+
if (!record) return null
|
|
346
|
+
const snapshot = snapshotWarehouseZone(record)
|
|
347
|
+
// The snapshot always carries custom values because undo has to restore them, and
|
|
348
|
+
// every caller (prepare on update/delete, captureAfter on create/update) feeds undo.
|
|
349
|
+
// For the common tenant that defined no zone custom fields this costs a single
|
|
350
|
+
// `custom_field_values` lookup: `loadCustomFieldValues` returns early on an empty
|
|
351
|
+
// result and never reaches the `custom_field_defs` query.
|
|
352
|
+
snapshot.custom = await loadCustomFieldSnapshot(em, {
|
|
353
|
+
entityId: E.wms.warehouse_zone,
|
|
354
|
+
recordId: record.id,
|
|
355
|
+
tenantId: record.tenantId,
|
|
356
|
+
organizationId: record.organizationId,
|
|
357
|
+
})
|
|
358
|
+
return snapshot
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function writeZoneCustomFields(
|
|
362
|
+
ctx: CommandRuntimeContext,
|
|
363
|
+
zone: { id: string; organizationId: string; tenantId: string },
|
|
364
|
+
values: Record<string, unknown>,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
if (!values || !Object.keys(values).length) return
|
|
367
|
+
await setCustomFieldsIfAny({
|
|
368
|
+
dataEngine: ctx.container.resolve('dataEngine') as DataEngine,
|
|
369
|
+
entityId: E.wms.warehouse_zone,
|
|
370
|
+
recordId: zone.id,
|
|
371
|
+
organizationId: zone.organizationId,
|
|
372
|
+
tenantId: zone.tenantId,
|
|
373
|
+
values,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Zone reads go through the hybrid query engine, which projects `cf:*` from
|
|
378
|
+
// `entity_indexes`. Emitting the CRUD side effect is therefore what makes a
|
|
379
|
+
// custom field value written above observable through the zones list API — without
|
|
380
|
+
// it the value sits in `custom_field_values` that no read path consults (#5239).
|
|
381
|
+
// `events` is deliberately omitted: the module already emits `wms.zone.*` itself,
|
|
382
|
+
// and adding an events config here would introduce a second, undeclared
|
|
383
|
+
// `wms.warehouse_zone.*` event id for the same write.
|
|
384
|
+
async function emitZoneCrudSideEffects(
|
|
385
|
+
ctx: CommandRuntimeContext,
|
|
386
|
+
action: 'created' | 'updated' | 'deleted',
|
|
387
|
+
zone: WarehouseZone,
|
|
388
|
+
origin: 'write' | 'undo' = 'write',
|
|
389
|
+
): Promise<void> {
|
|
390
|
+
const options = {
|
|
391
|
+
dataEngine: ctx.container.resolve('dataEngine') as DataEngine,
|
|
392
|
+
action,
|
|
393
|
+
entity: zone,
|
|
394
|
+
identifiers: {
|
|
395
|
+
id: zone.id,
|
|
396
|
+
organizationId: zone.organizationId,
|
|
397
|
+
tenantId: zone.tenantId,
|
|
398
|
+
},
|
|
399
|
+
indexer: warehouseZoneCrudIndexer,
|
|
400
|
+
}
|
|
401
|
+
if (origin === 'undo') {
|
|
402
|
+
await emitCrudUndoSideEffects(options)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
await emitCrudSideEffects(options)
|
|
335
406
|
}
|
|
336
407
|
|
|
337
408
|
function snapshotWarehouseLocation(record: WarehouseLocation): WarehouseLocationSnapshot {
|
|
@@ -989,7 +1060,7 @@ const deleteWarehouseCommand: CommandHandler<{ id?: string }, { warehouseId: str
|
|
|
989
1060
|
},
|
|
990
1061
|
}
|
|
991
1062
|
|
|
992
|
-
async function restoreZoneFromSnapshot(em: EntityManager, before: WarehouseZoneSnapshot): Promise<
|
|
1063
|
+
async function restoreZoneFromSnapshot(em: EntityManager, before: WarehouseZoneSnapshot): Promise<WarehouseZone> {
|
|
993
1064
|
const scope = { tenantId: before.tenantId, organizationId: before.organizationId }
|
|
994
1065
|
const warehouseRef = await findOneWithDecryption(em, Warehouse, { id: before.warehouseId }, undefined, scope)
|
|
995
1066
|
if (!warehouseRef) {
|
|
@@ -1018,12 +1089,13 @@ async function restoreZoneFromSnapshot(em: EntityManager, before: WarehouseZoneS
|
|
|
1018
1089
|
record.metadata = before.metadata
|
|
1019
1090
|
record.deletedAt = null
|
|
1020
1091
|
}
|
|
1092
|
+
return record
|
|
1021
1093
|
}
|
|
1022
1094
|
|
|
1023
1095
|
const createWarehouseZoneCommand: CommandHandler<WarehouseZoneCreateInput, { zoneId: string }> = {
|
|
1024
1096
|
id: 'wms.zones.create',
|
|
1025
1097
|
async execute(rawInput, ctx) {
|
|
1026
|
-
const parsed = warehouseZoneCreateSchema
|
|
1098
|
+
const { parsed, custom } = parseWithCustomFields(warehouseZoneCreateSchema, rawInput ?? {})
|
|
1027
1099
|
ensureTenantScope(ctx, parsed.tenantId)
|
|
1028
1100
|
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
1029
1101
|
const em = resolveEm(ctx)
|
|
@@ -1039,6 +1111,8 @@ const createWarehouseZoneCommand: CommandHandler<WarehouseZoneCreateInput, { zon
|
|
|
1039
1111
|
metadata: toJsonValue(parsed.metadata),
|
|
1040
1112
|
})
|
|
1041
1113
|
await em.persist(zone).flush()
|
|
1114
|
+
await writeZoneCustomFields(ctx, zone, custom)
|
|
1115
|
+
await emitZoneCrudSideEffects(ctx, 'created', zone)
|
|
1042
1116
|
void emitWmsEvent('wms.zone.created', {
|
|
1043
1117
|
id: zone.id,
|
|
1044
1118
|
zoneId: zone.id,
|
|
@@ -1074,6 +1148,8 @@ const createWarehouseZoneCommand: CommandHandler<WarehouseZoneCreateInput, { zon
|
|
|
1074
1148
|
ensureOrganizationScope(ctx, record.organizationId)
|
|
1075
1149
|
record.deletedAt = new Date()
|
|
1076
1150
|
await em.flush()
|
|
1151
|
+
await writeZoneCustomFields(ctx, record, buildCustomFieldResetMap(undefined, after.custom))
|
|
1152
|
+
await emitZoneCrudSideEffects(ctx, 'deleted', record, 'undo')
|
|
1077
1153
|
},
|
|
1078
1154
|
}
|
|
1079
1155
|
|
|
@@ -1086,7 +1162,7 @@ const updateWarehouseZoneCommand: CommandHandler<WarehouseZoneUpdateInput, { zon
|
|
|
1086
1162
|
return before ? { before } : {}
|
|
1087
1163
|
},
|
|
1088
1164
|
async execute(rawInput, ctx) {
|
|
1089
|
-
const parsed = warehouseZoneUpdateSchema
|
|
1165
|
+
const { parsed, custom } = parseWithCustomFields(warehouseZoneUpdateSchema, rawInput ?? {})
|
|
1090
1166
|
const em = resolveEm(ctx)
|
|
1091
1167
|
const zone = await loadZone(em, ctx, parsed.id)
|
|
1092
1168
|
if (parsed.warehouseId !== undefined) {
|
|
@@ -1102,6 +1178,8 @@ const updateWarehouseZoneCommand: CommandHandler<WarehouseZoneUpdateInput, { zon
|
|
|
1102
1178
|
if (parsed.priority !== undefined) zone.priority = parsed.priority
|
|
1103
1179
|
if (parsed.metadata !== undefined) zone.metadata = toJsonValue(parsed.metadata)
|
|
1104
1180
|
await em.flush()
|
|
1181
|
+
await writeZoneCustomFields(ctx, zone, custom)
|
|
1182
|
+
await emitZoneCrudSideEffects(ctx, 'updated', zone)
|
|
1105
1183
|
void emitWmsEvent('wms.zone.updated', {
|
|
1106
1184
|
id: zone.id,
|
|
1107
1185
|
zoneId: zone.id,
|
|
@@ -1128,8 +1206,10 @@ const updateWarehouseZoneCommand: CommandHandler<WarehouseZoneUpdateInput, { zon
|
|
|
1128
1206
|
const em = resolveEm(ctx)
|
|
1129
1207
|
ensureTenantScope(ctx, before.tenantId)
|
|
1130
1208
|
ensureOrganizationScope(ctx, before.organizationId)
|
|
1131
|
-
await restoreZoneFromSnapshot(em, before)
|
|
1209
|
+
const record = await restoreZoneFromSnapshot(em, before)
|
|
1132
1210
|
await em.flush()
|
|
1211
|
+
await writeZoneCustomFields(ctx, record, buildCustomFieldResetMap(before.custom, payload?.after?.custom))
|
|
1212
|
+
await emitZoneCrudSideEffects(ctx, 'updated', record, 'undo')
|
|
1133
1213
|
},
|
|
1134
1214
|
}
|
|
1135
1215
|
|
|
@@ -1147,6 +1227,7 @@ const deleteWarehouseZoneCommand: CommandHandler<{ id?: string }, { zoneId: stri
|
|
|
1147
1227
|
const zone = await loadZone(em, ctx, zoneId)
|
|
1148
1228
|
zone.deletedAt = new Date()
|
|
1149
1229
|
await em.flush()
|
|
1230
|
+
await emitZoneCrudSideEffects(ctx, 'deleted', zone)
|
|
1150
1231
|
return { zoneId: zone.id }
|
|
1151
1232
|
},
|
|
1152
1233
|
buildLog: async ({ input, result, ctx, snapshots }) => {
|
|
@@ -1161,8 +1242,10 @@ const deleteWarehouseZoneCommand: CommandHandler<{ id?: string }, { zoneId: stri
|
|
|
1161
1242
|
const em = resolveEm(ctx)
|
|
1162
1243
|
ensureTenantScope(ctx, before.tenantId)
|
|
1163
1244
|
ensureOrganizationScope(ctx, before.organizationId)
|
|
1164
|
-
await restoreZoneFromSnapshot(em, before)
|
|
1245
|
+
const record = await restoreZoneFromSnapshot(em, before)
|
|
1165
1246
|
await em.flush()
|
|
1247
|
+
await writeZoneCustomFields(ctx, record, buildCustomFieldResetMap(before.custom, undefined))
|
|
1248
|
+
await emitZoneCrudSideEffects(ctx, 'created', record, 'undo')
|
|
1166
1249
|
},
|
|
1167
1250
|
}
|
|
1168
1251
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
InventoryBalance,
|
|
8
8
|
InventoryMovement,
|
|
9
9
|
InventoryReservation,
|
|
10
|
+
WarehouseZone,
|
|
10
11
|
} from '../data/entities'
|
|
11
12
|
|
|
12
13
|
export function ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string): void {
|
|
@@ -48,6 +49,13 @@ export const inventoryMovementCrudIndexer: CrudIndexerConfig<InventoryMovement>
|
|
|
48
49
|
entityType: E.wms.inventory_movement,
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// Zones are read through the hybrid query engine, which projects `cf:*` out of
|
|
53
|
+
// `entity_indexes` rather than joining `custom_field_values`. Without this indexer the
|
|
54
|
+
// zone commands write custom field values that no read path can ever see (#5239).
|
|
55
|
+
export const warehouseZoneCrudIndexer: CrudIndexerConfig<WarehouseZone> = {
|
|
56
|
+
entityType: E.wms.warehouse_zone,
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
export const inventoryBalanceCrudEvents: CrudEventsConfig<InventoryBalance> = {
|
|
52
60
|
module: 'wms',
|
|
53
61
|
entity: 'inventory_balance',
|
|
@@ -12,10 +12,12 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
|
12
12
|
import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
13
13
|
import { EmptyState } from '@open-mercato/ui/backend/EmptyState'
|
|
14
14
|
import { CrudForm, type CrudField, type CrudFieldOption } from '@open-mercato/ui/backend/CrudForm'
|
|
15
|
+
import { extractCustomFieldEntries } from '@open-mercato/shared/lib/crud/custom-fields-client'
|
|
15
16
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
16
17
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
17
18
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
18
19
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
20
|
+
import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
|
|
19
21
|
import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
20
22
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
21
23
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
@@ -80,6 +82,11 @@ type ZoneRow = {
|
|
|
80
82
|
code?: string | null
|
|
81
83
|
name?: string | null
|
|
82
84
|
priority?: number | null
|
|
85
|
+
updated_at?: string | null
|
|
86
|
+
// The zones list route decorates with `stripPrefixedKeys: true`, so custom values
|
|
87
|
+
// arrive only under this canonical key — never as top-level `cf_*` / `cf:*`.
|
|
88
|
+
customValues?: Record<string, unknown> | null
|
|
89
|
+
customFields?: Array<{ key: string; label: string | null; value: unknown; kind: string | null; multi: boolean }>
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
type InventoryProfileRow = {
|
|
@@ -109,12 +116,17 @@ type WarehouseFormValues = {
|
|
|
109
116
|
isPrimary: boolean
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
// `id` / `updatedAt` are carried in edit mode so CrudForm recognises the form as an edit
|
|
120
|
+
// and derives the optimistic-lock header; the `cf_` index signature holds the values
|
|
121
|
+
// collected from the injected custom-field inputs.
|
|
112
122
|
type ZoneFormValues = {
|
|
113
123
|
warehouseId: string
|
|
114
124
|
code: string
|
|
115
125
|
name: string
|
|
116
126
|
priority?: number
|
|
117
|
-
|
|
127
|
+
id?: string
|
|
128
|
+
updatedAt?: string | null
|
|
129
|
+
} & { [key: `cf_${string}`]: unknown }
|
|
118
130
|
|
|
119
131
|
type InventoryProfileFormValues = {
|
|
120
132
|
catalogProductId: string
|
|
@@ -142,12 +154,15 @@ const warehouseFormSchema = z.object({
|
|
|
142
154
|
isPrimary: z.boolean().default(false),
|
|
143
155
|
})
|
|
144
156
|
|
|
157
|
+
// passthrough so CrudForm's schema.safeParse keeps the `cf_*` custom field values
|
|
158
|
+
// it collected from the injected custom-field inputs — a plain z.object strips them
|
|
159
|
+
// before onSubmit ever sees them.
|
|
145
160
|
const zoneFormSchema = z.object({
|
|
146
161
|
warehouseId: z.string().uuid(),
|
|
147
162
|
code: z.string().trim().min(1).max(80),
|
|
148
163
|
name: z.string().trim().min(1).max(200),
|
|
149
164
|
priority: z.coerce.number().int().min(0).optional(),
|
|
150
|
-
})
|
|
165
|
+
}).passthrough()
|
|
151
166
|
|
|
152
167
|
function buildInventoryProfileFormSchema(fefoRequiredMsg: string) {
|
|
153
168
|
return z.object({
|
|
@@ -598,19 +613,54 @@ export function ZoneSection({ viewAllHref }: ConfigSectionOptions = {}) {
|
|
|
598
613
|
},
|
|
599
614
|
})
|
|
600
615
|
|
|
616
|
+
// Warehouse options for the dialog's combobox. Loaded whenever the dialog opens so a tenant
|
|
617
|
+
// with a single warehouse gets it pre-selected instead of an empty "type to search" input,
|
|
618
|
+
// and so the edit dialog can label the currently linked warehouse without a lookup round-trip.
|
|
619
|
+
const warehouseOptionsQuery = useQuery({
|
|
620
|
+
queryKey: ['wms-config', 'zones', 'warehouse-options'],
|
|
621
|
+
queryFn: () => loadWarehouseOptions(),
|
|
622
|
+
enabled: dialog !== null,
|
|
623
|
+
staleTime: 30_000,
|
|
624
|
+
})
|
|
625
|
+
const warehouseOptions = React.useMemo(
|
|
626
|
+
() => warehouseOptionsQuery.data ?? [],
|
|
627
|
+
[warehouseOptionsQuery.data],
|
|
628
|
+
)
|
|
629
|
+
const soleWarehouseId = warehouseOptions.length === 1 ? warehouseOptions[0].value : null
|
|
630
|
+
|
|
631
|
+
const warehouseSeedOptions = React.useMemo<CrudFieldOption[] | undefined>(() => {
|
|
632
|
+
if (dialog?.mode === 'edit') {
|
|
633
|
+
const warehouseId = dialog.row.warehouse_id
|
|
634
|
+
if (!warehouseId) return undefined
|
|
635
|
+
const label = dialog.row.warehouse_name?.trim() || dialog.row.warehouse_code?.trim() || ''
|
|
636
|
+
return label ? [{ value: warehouseId, label }] : undefined
|
|
637
|
+
}
|
|
638
|
+
return soleWarehouseId ? warehouseOptions : undefined
|
|
639
|
+
}, [dialog, soleWarehouseId, warehouseOptions])
|
|
640
|
+
|
|
641
|
+
// The dialog already holds the first page of warehouses; serve the combobox's initial
|
|
642
|
+
// (unsearched) open from that cache instead of repeating the same request, and only go
|
|
643
|
+
// back to the network once the user actually types a term the cached page cannot answer.
|
|
644
|
+
const loadZoneWarehouseOptions = React.useCallback(async (query?: string) => {
|
|
645
|
+
const term = query?.trim()
|
|
646
|
+
if (!term) return warehouseOptionsQuery.data ?? loadWarehouseOptions()
|
|
647
|
+
return loadWarehouseOptions(term)
|
|
648
|
+
}, [warehouseOptionsQuery.data])
|
|
649
|
+
|
|
601
650
|
const fields = React.useMemo<CrudField[]>(() => [
|
|
602
651
|
{
|
|
603
652
|
id: 'warehouseId',
|
|
604
653
|
type: 'combobox',
|
|
605
654
|
label: t('wms.backend.config.zones.form.warehouse', 'Warehouse'),
|
|
606
655
|
required: true,
|
|
607
|
-
loadOptions:
|
|
656
|
+
loadOptions: loadZoneWarehouseOptions,
|
|
608
657
|
allowCustomValues: false,
|
|
658
|
+
seedOptions: warehouseSeedOptions,
|
|
609
659
|
},
|
|
610
660
|
{ id: 'code', type: 'text', label: t('wms.backend.config.zones.form.code', 'Code'), required: true },
|
|
611
661
|
{ id: 'name', type: 'text', label: t('wms.backend.config.zones.form.name', 'Name'), required: true },
|
|
612
662
|
{ id: 'priority', type: 'number', label: t('wms.backend.config.zones.form.priority', 'Priority') },
|
|
613
|
-
], [t])
|
|
663
|
+
], [t, loadZoneWarehouseOptions, warehouseSeedOptions])
|
|
614
664
|
|
|
615
665
|
const columns = React.useMemo<ColumnDef<ZoneRow>[]>(() => [
|
|
616
666
|
{
|
|
@@ -641,19 +691,22 @@ export function ZoneSection({ viewAllHref }: ConfigSectionOptions = {}) {
|
|
|
641
691
|
const initialValues = React.useMemo<ZoneFormValues>(() => {
|
|
642
692
|
if (dialog?.mode === 'edit') {
|
|
643
693
|
return {
|
|
694
|
+
id: dialog.row.id,
|
|
695
|
+
updatedAt: dialog.row.updated_at ?? null,
|
|
644
696
|
warehouseId: dialog.row.warehouse_id || '',
|
|
645
697
|
code: dialog.row.code || '',
|
|
646
698
|
name: dialog.row.name || '',
|
|
647
699
|
priority: dialog.row.priority == null ? undefined : Number(dialog.row.priority),
|
|
700
|
+
...extractCustomFieldEntries(dialog.row),
|
|
648
701
|
}
|
|
649
702
|
}
|
|
650
703
|
return {
|
|
651
|
-
warehouseId: '',
|
|
704
|
+
warehouseId: soleWarehouseId ?? '',
|
|
652
705
|
code: '',
|
|
653
706
|
name: '',
|
|
654
707
|
priority: undefined,
|
|
655
708
|
}
|
|
656
|
-
}, [dialog])
|
|
709
|
+
}, [dialog, soleWarehouseId])
|
|
657
710
|
|
|
658
711
|
const closeDialog = React.useCallback(() => {
|
|
659
712
|
setDialog(null)
|
|
@@ -669,10 +722,14 @@ export function ZoneSection({ viewAllHref }: ConfigSectionOptions = {}) {
|
|
|
669
722
|
const submitMode = dialog.mode
|
|
670
723
|
setSubmitting(true)
|
|
671
724
|
try {
|
|
672
|
-
const
|
|
673
|
-
|
|
725
|
+
const customFields = collectCustomFieldValues(values)
|
|
726
|
+
const payload: Record<string, unknown> = {
|
|
727
|
+
warehouseId: values.warehouseId,
|
|
728
|
+
code: values.code,
|
|
729
|
+
name: values.name,
|
|
674
730
|
priority: values.priority === undefined || Number.isNaN(values.priority) ? undefined : Number(values.priority),
|
|
675
731
|
}
|
|
732
|
+
if (Object.keys(customFields).length) payload.customFields = customFields
|
|
676
733
|
await runMutation({
|
|
677
734
|
operation: async () => {
|
|
678
735
|
const call = await apiCall(
|