@open-mercato/core 0.6.6-develop.6257.1.6d0af84d26 → 0.6.6-develop.6290.1.4bb5a8ba3f
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 +2 -2
- package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
- package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +10 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/catalog/lib/productResolution.js +45 -0
- package/dist/modules/catalog/lib/productResolution.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
- package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
- package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
- package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +176 -161
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +75 -59
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
- package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
- package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
- package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
- package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
- 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 +11 -17
- package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
- package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
- package/dist/modules/data_sync/lib/id-mapping.js +46 -1
- package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
- package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
- package/dist/modules/data_sync/workers/sync-scheduled.js +13 -14
- package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
- package/dist/modules/directory/components/TenantSelect.js +29 -16
- package/dist/modules/directory/components/TenantSelect.js.map +2 -2
- package/dist/modules/entities/api/records.js +6 -0
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/feature_toggles/api/global/route.js +17 -15
- package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
- package/dist/modules/feature_toggles/api/openapi.js +2 -2
- package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
- package/dist/modules/feature_toggles/commands/global.js +14 -0
- package/dist/modules/feature_toggles/commands/global.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
- package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
- package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
- package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +5 -0
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/refund/route.js +5 -0
- package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
- package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
- package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
- package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
- package/dist/modules/perspectives/data/entities.js +33 -3
- package/dist/modules/perspectives/data/entities.js.map +2 -2
- package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
- package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
- package/dist/modules/perspectives/services/perspectiveService.js +17 -0
- package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +33 -1
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js +35 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js.map +7 -0
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +18 -19
- package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
- package/dist/modules/progress/api/active/route.js +1 -1
- package/dist/modules/progress/api/active/route.js.map +2 -2
- package/dist/modules/progress/lib/progressService.js.map +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/dist/modules/query_index/api/status.js +19 -24
- package/dist/modules/query_index/api/status.js.map +3 -3
- package/dist/modules/query_index/lib/coverage.js +35 -0
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +11 -6
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/resources/api/resource-types.js +5 -37
- package/dist/modules/resources/api/resource-types.js.map +2 -2
- package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
- package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
- package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
- package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
- package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
- package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
- package/dist/modules/sales/di.js +4 -0
- package/dist/modules/sales/di.js.map +2 -2
- package/dist/modules/sales/services/salesOrderService.js +26 -0
- package/dist/modules/sales/services/salesOrderService.js.map +7 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/shipping_carriers/data/entities.js +38 -0
- package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
- package/dist/modules/shipping_carriers/data/validators.js +2 -1
- package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
- package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
- package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
- package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +9 -18
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +10 -0
- package/package.json +9 -8
- package/src/modules/catalog/lib/productResolution.ts +75 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
- package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
- package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
- package/src/modules/currencies/i18n/de.json +2 -1
- package/src/modules/currencies/i18n/en.json +2 -1
- package/src/modules/currencies/i18n/es.json +2 -1
- package/src/modules/currencies/i18n/pl.json +2 -1
- package/src/modules/customers/api/companies/[id]/route.ts +214 -189
- package/src/modules/customers/api/people/[id]/route.ts +106 -80
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
- package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
- package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
- package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
- package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
- package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
- package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
- package/src/modules/data_sync/lib/id-mapping.ts +58 -1
- package/src/modules/data_sync/lib/sync-engine.ts +2 -2
- package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
- package/src/modules/data_sync/workers/sync-scheduled.ts +13 -14
- package/src/modules/directory/components/TenantSelect.tsx +33 -16
- package/src/modules/entities/api/records.ts +14 -0
- package/src/modules/feature_toggles/api/global/route.ts +16 -14
- package/src/modules/feature_toggles/api/openapi.ts +2 -2
- package/src/modules/feature_toggles/commands/global.ts +15 -0
- package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
- package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
- package/src/modules/payment_gateways/api/capture/route.ts +5 -0
- package/src/modules/payment_gateways/api/refund/route.ts +5 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
- package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
- package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
- package/src/modules/perspectives/data/entities.ts +43 -3
- package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
- package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
- package/src/modules/perspectives/services/perspectiveService.ts +17 -0
- package/src/modules/planner/commands/availability-weekly.ts +48 -1
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
- package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.ts +36 -0
- package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +22 -23
- package/src/modules/progress/api/active/route.ts +1 -1
- package/src/modules/progress/lib/progressService.ts +2 -2
- package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
- package/src/modules/query_index/api/status.ts +28 -32
- package/src/modules/query_index/lib/coverage.ts +63 -0
- package/src/modules/query_index/lib/engine.ts +37 -24
- package/src/modules/resources/api/resource-types.ts +4 -44
- package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
- package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
- package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
- package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
- package/src/modules/sales/di.ts +6 -0
- package/src/modules/sales/services/salesOrderService.ts +38 -0
- package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
- package/src/modules/shipping_carriers/data/entities.ts +33 -0
- package/src/modules/shipping_carriers/data/validators.ts +1 -0
- package/src/modules/shipping_carriers/i18n/de.json +1 -0
- package/src/modules/shipping_carriers/i18n/en.json +1 -0
- package/src/modules/shipping_carriers/i18n/es.json +1 -0
- package/src/modules/shipping_carriers/i18n/pl.json +1 -0
- package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
- package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
- package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
- package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
- package/src/modules/translations/components/TranslationManager.tsx +17 -24
- package/src/modules/translations/i18n/de.json +1 -0
- package/src/modules/translations/i18n/en.json +1 -0
- package/src/modules/translations/i18n/es.json +1 -0
- package/src/modules/translations/i18n/pl.json +1 -0
|
@@ -3,11 +3,10 @@ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
|
3
3
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
4
4
|
import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
|
|
5
5
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
6
|
-
import
|
|
7
|
-
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
8
|
-
import { ResourcesResource, ResourcesResourceType } from '../data/entities'
|
|
6
|
+
import { ResourcesResourceType } from '../data/entities'
|
|
9
7
|
import { resourcesResourceTypeCreateSchema, resourcesResourceTypeUpdateSchema } from '../data/validators'
|
|
10
8
|
import { sanitizeSearchTerm } from './helpers'
|
|
9
|
+
import { attachResourceTypeCounts } from './resourceTypeCounts'
|
|
11
10
|
import { E } from '#generated/entities.ids.generated'
|
|
12
11
|
import { createResourcesCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from './openapi'
|
|
13
12
|
|
|
@@ -47,6 +46,7 @@ const listSchema = z
|
|
|
47
46
|
ids: z.string().optional(),
|
|
48
47
|
sortField: z.string().optional(),
|
|
49
48
|
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
49
|
+
withResourceCounts: z.string().optional(),
|
|
50
50
|
})
|
|
51
51
|
.passthrough()
|
|
52
52
|
|
|
@@ -131,47 +131,7 @@ const crud = makeCrudRoute({
|
|
|
131
131
|
},
|
|
132
132
|
},
|
|
133
133
|
hooks: {
|
|
134
|
-
afterList:
|
|
135
|
-
const items: Array<Record<string, unknown>> = Array.isArray(payload?.items)
|
|
136
|
-
? (payload.items as Array<Record<string, unknown>>)
|
|
137
|
-
: []
|
|
138
|
-
if (!items.length) return
|
|
139
|
-
const typeIds = items
|
|
140
|
-
.map((item) => (typeof item.id === 'string' ? item.id : null))
|
|
141
|
-
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
142
|
-
if (!typeIds.length) return
|
|
143
|
-
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
144
|
-
const tenantId = ctx.organizationScope?.tenantId ?? ctx.auth?.tenantId ?? null
|
|
145
|
-
const orgIds = ctx.organizationIds ?? ctx.organizationScope?.filterIds ?? null
|
|
146
|
-
const orgFilter =
|
|
147
|
-
Array.isArray(orgIds) && orgIds.length > 0
|
|
148
|
-
? { $in: orgIds }
|
|
149
|
-
: ctx.selectedOrganizationId ?? ctx.organizationScope?.selectedId ?? null
|
|
150
|
-
const scope = { tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.organizationScope?.selectedId ?? null }
|
|
151
|
-
const resources = await findWithDecryption(
|
|
152
|
-
em,
|
|
153
|
-
ResourcesResource,
|
|
154
|
-
{
|
|
155
|
-
resourceTypeId: { $in: typeIds },
|
|
156
|
-
deletedAt: null,
|
|
157
|
-
...(tenantId ? { tenantId } : {}),
|
|
158
|
-
...(orgFilter ? { organizationId: orgFilter } : {}),
|
|
159
|
-
},
|
|
160
|
-
{ fields: ['id', 'resourceTypeId'] },
|
|
161
|
-
scope,
|
|
162
|
-
)
|
|
163
|
-
const countMap = new Map<string, number>()
|
|
164
|
-
resources.forEach((resource) => {
|
|
165
|
-
const typeId = resource.resourceTypeId ?? null
|
|
166
|
-
if (!typeId) return
|
|
167
|
-
countMap.set(typeId, (countMap.get(typeId) ?? 0) + 1)
|
|
168
|
-
})
|
|
169
|
-
items.forEach((item) => {
|
|
170
|
-
const id = typeof item.id === 'string' ? item.id : null
|
|
171
|
-
if (!id) return
|
|
172
|
-
item.resourceCount = countMap.get(id) ?? 0
|
|
173
|
-
})
|
|
174
|
-
},
|
|
134
|
+
afterList: (payload, ctx) => attachResourceTypeCounts(payload, ctx),
|
|
175
135
|
},
|
|
176
136
|
})
|
|
177
137
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely'
|
|
2
|
+
import { sql } from 'kysely'
|
|
3
|
+
import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
|
|
4
|
+
import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
5
|
+
|
|
6
|
+
type ResourceCountDatabase = {
|
|
7
|
+
resources_resources: {
|
|
8
|
+
resource_type_id: string | null
|
|
9
|
+
deleted_at: Date | null
|
|
10
|
+
tenant_id: string | null
|
|
11
|
+
organization_id: string | null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type KyselyCapableEntityManager = { getKysely: () => Kysely<ResourceCountDatabase> }
|
|
16
|
+
|
|
17
|
+
type ResourceTypeCountContext = Pick<
|
|
18
|
+
CrudCtx,
|
|
19
|
+
'container' | 'auth' | 'organizationScope' | 'organizationIds' | 'selectedOrganizationId'
|
|
20
|
+
> & { query?: { withResourceCounts?: unknown } }
|
|
21
|
+
|
|
22
|
+
type ResourceTypeListPayload = { items?: unknown } | null | undefined
|
|
23
|
+
|
|
24
|
+
type ResourceCountRow = { resource_type_id: string | null; count: string | number }
|
|
25
|
+
|
|
26
|
+
export async function attachResourceTypeCounts(
|
|
27
|
+
payload: ResourceTypeListPayload,
|
|
28
|
+
ctx: ResourceTypeCountContext,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
// Resource counts are opt-in: only callers that render the count (the
|
|
31
|
+
// resource-types admin table and its delete gate) pass `withResourceCounts`.
|
|
32
|
+
// Select/filter callers (resource dropdowns, group labels) skip the scan
|
|
33
|
+
// entirely so ordinary type lookups no longer pay an O(resources) cost.
|
|
34
|
+
if (!parseBooleanFromUnknown(ctx.query?.withResourceCounts)) return
|
|
35
|
+
|
|
36
|
+
const items = Array.isArray(payload?.items)
|
|
37
|
+
? (payload!.items as Array<Record<string, unknown>>)
|
|
38
|
+
: []
|
|
39
|
+
if (!items.length) return
|
|
40
|
+
|
|
41
|
+
const typeIds = items
|
|
42
|
+
.map((item) => (typeof item.id === 'string' ? item.id : null))
|
|
43
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
44
|
+
if (!typeIds.length) return
|
|
45
|
+
|
|
46
|
+
const tenantId = ctx.organizationScope?.tenantId ?? ctx.auth?.tenantId ?? null
|
|
47
|
+
const orgIds = ctx.organizationIds ?? ctx.organizationScope?.filterIds ?? null
|
|
48
|
+
const singleOrgId = ctx.selectedOrganizationId ?? ctx.organizationScope?.selectedId ?? null
|
|
49
|
+
|
|
50
|
+
const db = (ctx.container.resolve('em') as unknown as KyselyCapableEntityManager).getKysely()
|
|
51
|
+
let query = db
|
|
52
|
+
.selectFrom('resources_resources')
|
|
53
|
+
.select(['resource_type_id', sql<string>`count(*)`.as('count')])
|
|
54
|
+
.where('resource_type_id', 'in', typeIds)
|
|
55
|
+
.where('deleted_at', 'is', null)
|
|
56
|
+
if (tenantId) query = query.where('tenant_id', '=', tenantId)
|
|
57
|
+
if (Array.isArray(orgIds) && orgIds.length > 0) {
|
|
58
|
+
query = query.where('organization_id', 'in', orgIds)
|
|
59
|
+
} else if (singleOrgId) {
|
|
60
|
+
query = query.where('organization_id', '=', singleOrgId)
|
|
61
|
+
}
|
|
62
|
+
const rows = (await query.groupBy('resource_type_id').execute()) as ResourceCountRow[]
|
|
63
|
+
|
|
64
|
+
const countMap = new Map<string, number>()
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
if (typeof row.resource_type_id !== 'string') continue
|
|
67
|
+
const count = typeof row.count === 'string' ? Number.parseInt(row.count, 10) : Number(row.count)
|
|
68
|
+
countMap.set(row.resource_type_id, Number.isFinite(count) ? count : 0)
|
|
69
|
+
}
|
|
70
|
+
for (const item of items) {
|
|
71
|
+
if (typeof item.id !== 'string') continue
|
|
72
|
+
item.resourceCount = countMap.get(item.id) ?? 0
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -34,7 +34,7 @@ export default function ResourcesResourceTypeEditPage({ params }: { params?: { i
|
|
|
34
34
|
setIsNotFound(false)
|
|
35
35
|
try {
|
|
36
36
|
const payload = await readApiResultOrThrow<ResourceTypesResponse>(
|
|
37
|
-
`/api/resources/resource-types?ids=${encodeURIComponent(resourceTypeId)}&page=1&pageSize=1`,
|
|
37
|
+
`/api/resources/resource-types?ids=${encodeURIComponent(resourceTypeId)}&page=1&pageSize=1&withResourceCounts=true`,
|
|
38
38
|
undefined,
|
|
39
39
|
{ errorMessage: t('resources.resourceTypes.errors.load', 'Failed to load resource types.') },
|
|
40
40
|
)
|
|
@@ -11,6 +11,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
11
11
|
import { apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
12
12
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
13
13
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
14
|
+
import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
14
15
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
15
16
|
import { computeAvailableReturnQuantity } from '@open-mercato/core/modules/sales/lib/returnQuantity'
|
|
16
17
|
import { handleSectionMutationError } from './optimisticLock'
|
|
@@ -136,7 +137,9 @@ export function ReturnDialog({ open, orderId, lines, documentUpdatedAt, onClose,
|
|
|
136
137
|
onClose()
|
|
137
138
|
return
|
|
138
139
|
}
|
|
139
|
-
|
|
140
|
+
const normalized = normalizeCrudServerError(err)
|
|
141
|
+
const fallback = t('sales.returns.errors.create', 'Failed to create return.')
|
|
142
|
+
flash(normalized.message || fallback, 'error')
|
|
140
143
|
} finally {
|
|
141
144
|
setSaving(false)
|
|
142
145
|
}
|
|
@@ -255,6 +255,20 @@ const extractShipmentAddressSnapshot = (
|
|
|
255
255
|
return normalizeAddressSnapshot(raw as Record<string, unknown>)
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
const addressOptionsSignature = (options: ShipmentAddressOption[]): string =>
|
|
259
|
+
options.map((option) => `${snapshotKey(option.snapshot) ?? ''}::${option.id}`).join('||')
|
|
260
|
+
|
|
261
|
+
function useStableAddressOptions(options: ShipmentAddressOption[]): ShipmentAddressOption[] {
|
|
262
|
+
const signature = addressOptionsSignature(options)
|
|
263
|
+
const stableRef = React.useRef(options)
|
|
264
|
+
const signatureRef = React.useRef(signature)
|
|
265
|
+
if (signatureRef.current !== signature) {
|
|
266
|
+
signatureRef.current = signature
|
|
267
|
+
stableRef.current = options
|
|
268
|
+
}
|
|
269
|
+
return stableRef.current
|
|
270
|
+
}
|
|
271
|
+
|
|
258
272
|
export function ShipmentDialog({
|
|
259
273
|
open,
|
|
260
274
|
mode,
|
|
@@ -323,7 +337,7 @@ export function ShipmentDialog({
|
|
|
323
337
|
[shipmentAddressSnapshot, t],
|
|
324
338
|
)
|
|
325
339
|
|
|
326
|
-
const
|
|
340
|
+
const computedBaseAddressOptions = React.useMemo(
|
|
327
341
|
() =>
|
|
328
342
|
dedupeAddressOptions(
|
|
329
343
|
[shippingAddressOption, shipmentAddressOption].filter(
|
|
@@ -332,6 +346,7 @@ export function ShipmentDialog({
|
|
|
332
346
|
),
|
|
333
347
|
[shipmentAddressOption, shippingAddressOption],
|
|
334
348
|
)
|
|
349
|
+
const baseAddressOptions = useStableAddressOptions(computedBaseAddressOptions)
|
|
335
350
|
|
|
336
351
|
const preferredAddressId = React.useMemo(() => {
|
|
337
352
|
const shipmentKey = snapshotKey(shipmentAddressSnapshot)
|
|
@@ -452,7 +467,10 @@ export function ShipmentDialog({
|
|
|
452
467
|
|
|
453
468
|
const mergeAddressOptions = React.useCallback(
|
|
454
469
|
(options: ShipmentAddressOption[]) =>
|
|
455
|
-
setAddressOptions((prev) =>
|
|
470
|
+
setAddressOptions((prev) => {
|
|
471
|
+
const next = dedupeAddressOptions([...prev, ...options])
|
|
472
|
+
return next.length === prev.length ? prev : next
|
|
473
|
+
}),
|
|
456
474
|
[],
|
|
457
475
|
)
|
|
458
476
|
|
package/src/modules/sales/di.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { registerOptimisticLockReaders } from '@open-mercato/shared/lib/crud/opt
|
|
|
7
7
|
import { DefaultSalesCalculationService } from './services/salesCalculationService'
|
|
8
8
|
import { DefaultTaxCalculationService } from './services/taxCalculationService'
|
|
9
9
|
import { SalesDocumentNumberGenerator } from './services/salesDocumentNumberGenerator'
|
|
10
|
+
import { DefaultSalesOrderService } from './services/salesOrderService'
|
|
10
11
|
import {
|
|
11
12
|
SalesOrder,
|
|
12
13
|
SalesOrderLine,
|
|
@@ -85,6 +86,11 @@ export function register(container: AppContainer) {
|
|
|
85
86
|
})
|
|
86
87
|
.singleton()
|
|
87
88
|
.proxy(),
|
|
89
|
+
salesOrderService: asFunction(({ em }: AppCradle) => {
|
|
90
|
+
return new DefaultSalesOrderService(em)
|
|
91
|
+
})
|
|
92
|
+
.singleton()
|
|
93
|
+
.proxy(),
|
|
88
94
|
SalesOrder: asValue(SalesOrder),
|
|
89
95
|
SalesOrderLine: asValue(SalesOrderLine),
|
|
90
96
|
SalesOrderAdjustment: asValue(SalesOrderAdjustment),
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { SalesOrder } from '../data/entities'
|
|
4
|
+
|
|
5
|
+
export type SalesOrderLookupScope = {
|
|
6
|
+
organizationId: string
|
|
7
|
+
tenantId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SalesOrderService {
|
|
11
|
+
findByExternalReference(
|
|
12
|
+
externalReference: string,
|
|
13
|
+
scope: SalesOrderLookupScope,
|
|
14
|
+
): Promise<SalesOrder | null>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DefaultSalesOrderService implements SalesOrderService {
|
|
18
|
+
constructor(private readonly em: EntityManager) {}
|
|
19
|
+
|
|
20
|
+
async findByExternalReference(
|
|
21
|
+
externalReference: string,
|
|
22
|
+
scope: SalesOrderLookupScope,
|
|
23
|
+
): Promise<SalesOrder | null> {
|
|
24
|
+
if (!externalReference) return null
|
|
25
|
+
return findOneWithDecryption(
|
|
26
|
+
this.em,
|
|
27
|
+
SalesOrder,
|
|
28
|
+
{
|
|
29
|
+
externalReference,
|
|
30
|
+
organizationId: scope.organizationId,
|
|
31
|
+
tenantId: scope.tenantId,
|
|
32
|
+
deletedAt: null,
|
|
33
|
+
},
|
|
34
|
+
undefined,
|
|
35
|
+
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
validateCrudMutationGuard,
|
|
8
8
|
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
9
9
|
import type { ShippingCarrierService } from '../../lib/shipping-service'
|
|
10
|
+
import { isShipmentIdempotencyConflictError } from '../../lib/shipment-idempotency'
|
|
10
11
|
import { createShipmentSchema } from '../../data/validators'
|
|
11
12
|
import { shippingCarriersTag } from '../openapi'
|
|
12
13
|
|
|
@@ -83,6 +84,12 @@ export async function POST(req: Request) {
|
|
|
83
84
|
labelUrl: shipment.labelUrl,
|
|
84
85
|
}, { status: 201 })
|
|
85
86
|
} catch (error: unknown) {
|
|
87
|
+
if (isShipmentIdempotencyConflictError(error)) {
|
|
88
|
+
return NextResponse.json(
|
|
89
|
+
{ error: 'Shipment idempotency conflict', code: 'idempotency_conflict' },
|
|
90
|
+
{ status: 409 },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
86
93
|
const message = error instanceof Error ? error.message : 'Failed to create shipment'
|
|
87
94
|
return NextResponse.json({ error: message }, { status: 502 })
|
|
88
95
|
}
|
|
@@ -97,6 +104,7 @@ export const openApi = {
|
|
|
97
104
|
tags: [shippingCarriersTag],
|
|
98
105
|
responses: [
|
|
99
106
|
{ status: 201, description: 'Shipment created' },
|
|
107
|
+
{ status: 409, description: 'Idempotency conflict: the idempotency key was reused with a different payload' },
|
|
100
108
|
{ status: 422, description: 'Validation failed' },
|
|
101
109
|
{ status: 502, description: 'Provider upstream error' },
|
|
102
110
|
],
|
|
@@ -89,3 +89,36 @@ export class CarrierWebhookProcessedEvent {
|
|
|
89
89
|
@Property({ name: 'processed_at', type: Date, onCreate: () => new Date() })
|
|
90
90
|
processedAt: Date = new Date()
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
@Entity({ tableName: 'carrier_shipment_idempotency_keys' })
|
|
94
|
+
@Unique({
|
|
95
|
+
name: 'carrier_shipment_idempotency_keys_unique',
|
|
96
|
+
properties: ['idempotencyKey', 'providerKey', 'organizationId', 'tenantId'],
|
|
97
|
+
})
|
|
98
|
+
export class CarrierShipmentIdempotencyKey {
|
|
99
|
+
[OptionalProps]?: 'shipmentId' | 'createdAt'
|
|
100
|
+
|
|
101
|
+
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
|
|
102
|
+
id!: string
|
|
103
|
+
|
|
104
|
+
@Property({ name: 'provider_key', type: 'text' })
|
|
105
|
+
providerKey!: string
|
|
106
|
+
|
|
107
|
+
@Property({ name: 'idempotency_key', type: 'text' })
|
|
108
|
+
idempotencyKey!: string
|
|
109
|
+
|
|
110
|
+
@Property({ name: 'request_hash', type: 'text' })
|
|
111
|
+
requestHash!: string
|
|
112
|
+
|
|
113
|
+
@Property({ name: 'shipment_id', type: 'uuid', nullable: true })
|
|
114
|
+
shipmentId?: string | null
|
|
115
|
+
|
|
116
|
+
@Property({ name: 'organization_id', type: 'uuid' })
|
|
117
|
+
organizationId!: string
|
|
118
|
+
|
|
119
|
+
@Property({ name: 'tenant_id', type: 'uuid' })
|
|
120
|
+
tenantId!: string
|
|
121
|
+
|
|
122
|
+
@Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
|
|
123
|
+
createdAt: Date = new Date()
|
|
124
|
+
}
|
|
@@ -44,6 +44,7 @@ export const createShipmentSchema = z.object({
|
|
|
44
44
|
receiverEmail: emailField,
|
|
45
45
|
targetPoint: z.string().optional(),
|
|
46
46
|
c2cSendingMethod: z.enum(['parcel_locker', 'dispatch_order', 'pop', 'any_point']).optional(),
|
|
47
|
+
idempotencyKey: z.string().min(1).max(255).optional(),
|
|
47
48
|
})
|
|
48
49
|
|
|
49
50
|
export const trackingQuerySchema = z.object({
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Sendung konnte nicht erstellt werden.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Abgabepunkte konnten nicht abgerufen werden.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Preise konnten nicht abgerufen werden.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "Diese Sendung wurde bereits übermittelt. Aktualisieren Sie die Seite, bevor Sie es erneut versuchen.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Ungültige E-Mail-Adresse.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Ungültige Telefonnummer.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Transporteure konnten nicht geladen werden.",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Failed to create shipment.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Failed to fetch drop-off points.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Failed to fetch shipping rates.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "This shipment was already submitted. Refresh the page before retrying.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Invalid email address.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Invalid phone number.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Failed to load shipping providers.",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Error al crear el envio.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Error al obtener los puntos de entrega.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Error al obtener las tarifas.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "Este envío ya fue enviado. Actualiza la página antes de volver a intentarlo.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Dirección de correo electrónico no válida.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Número de teléfono no válido.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Error al cargar los transportistas.",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"shipping_carriers.create.error.create": "Nie udało się utworzyć przesyłki.",
|
|
15
15
|
"shipping_carriers.create.error.fetchDropOffPoints": "Nie udało się pobrać punktów nadania.",
|
|
16
16
|
"shipping_carriers.create.error.fetchRates": "Nie udało się pobrać stawek.",
|
|
17
|
+
"shipping_carriers.create.error.idempotencyConflict": "Ta przesyłka została już wysłana. Odśwież stronę przed ponowną próbą.",
|
|
17
18
|
"shipping_carriers.create.error.invalidEmail": "Nieprawidłowy adres e-mail.",
|
|
18
19
|
"shipping_carriers.create.error.invalidPhone": "Nieprawidłowy numer telefonu.",
|
|
19
20
|
"shipping_carriers.create.error.loadProviders": "Nie udało się załadować przewoźników.",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { UniqueConstraintViolationException } from '@mikro-orm/core'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
5
|
+
import { CarrierShipmentIdempotencyKey } from '../data/entities'
|
|
6
|
+
|
|
7
|
+
type Scope = { organizationId: string; tenantId: string }
|
|
8
|
+
|
|
9
|
+
// Use Symbol.for so the marker survives module duplication across bundle boundaries — an
|
|
10
|
+
// `instanceof` check returns false when the API route and the service load separate copies of
|
|
11
|
+
// this class in the production build, which would silently downgrade the 409 conflict to a 502.
|
|
12
|
+
const SHIPMENT_IDEMPOTENCY_CONFLICT_MARKER = Symbol.for('@open-mercato/ShipmentIdempotencyConflictError')
|
|
13
|
+
|
|
14
|
+
export class ShipmentIdempotencyConflictError extends Error {
|
|
15
|
+
readonly [SHIPMENT_IDEMPOTENCY_CONFLICT_MARKER] = true
|
|
16
|
+
readonly idempotencyKey: string
|
|
17
|
+
constructor(idempotencyKey: string) {
|
|
18
|
+
super(`[internal] Shipment idempotency conflict for key "${idempotencyKey}"`)
|
|
19
|
+
this.name = 'ShipmentIdempotencyConflictError'
|
|
20
|
+
this.idempotencyKey = idempotencyKey
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isShipmentIdempotencyConflictError(error: unknown): error is ShipmentIdempotencyConflictError {
|
|
25
|
+
return (
|
|
26
|
+
!!error &&
|
|
27
|
+
typeof error === 'object' &&
|
|
28
|
+
(error as Record<symbol, unknown>)[SHIPMENT_IDEMPOTENCY_CONFLICT_MARKER] === true
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stableSerialize(value: unknown): string {
|
|
33
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
|
|
34
|
+
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`
|
|
35
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
36
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
37
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
38
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`).join(',')}}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deterministic fingerprint of a shipment-create request so that reusing the same
|
|
43
|
+
* idempotency key with a conflicting payload can be detected. Key order is
|
|
44
|
+
* normalized and `undefined` values are dropped so semantically equal requests
|
|
45
|
+
* hash identically.
|
|
46
|
+
*/
|
|
47
|
+
export function computeShipmentRequestHash(fingerprint: unknown): string {
|
|
48
|
+
return createHash('sha256').update(stableSerialize(fingerprint)).digest('hex')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function findShipmentIdempotencyClaim(
|
|
52
|
+
em: EntityManager,
|
|
53
|
+
idempotencyKey: string,
|
|
54
|
+
providerKey: string,
|
|
55
|
+
scope: Scope,
|
|
56
|
+
): Promise<CarrierShipmentIdempotencyKey | null> {
|
|
57
|
+
return findOneWithDecryption(
|
|
58
|
+
em,
|
|
59
|
+
CarrierShipmentIdempotencyKey,
|
|
60
|
+
{
|
|
61
|
+
idempotencyKey,
|
|
62
|
+
providerKey,
|
|
63
|
+
organizationId: scope.organizationId,
|
|
64
|
+
tenantId: scope.tenantId,
|
|
65
|
+
},
|
|
66
|
+
undefined,
|
|
67
|
+
scope,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Atomically claims an idempotency key by inserting the dedup row. Returns the
|
|
73
|
+
* managed claim on success, or `null` when a concurrent request already inserted
|
|
74
|
+
* the same scoped key (the unique constraint fires), mirroring the webhook-event
|
|
75
|
+
* claim idiom.
|
|
76
|
+
*/
|
|
77
|
+
export async function claimShipmentIdempotency(
|
|
78
|
+
em: EntityManager,
|
|
79
|
+
idempotencyKey: string,
|
|
80
|
+
providerKey: string,
|
|
81
|
+
requestHash: string,
|
|
82
|
+
scope: Scope,
|
|
83
|
+
): Promise<CarrierShipmentIdempotencyKey | null> {
|
|
84
|
+
const record = em.create(CarrierShipmentIdempotencyKey, {
|
|
85
|
+
idempotencyKey,
|
|
86
|
+
providerKey,
|
|
87
|
+
requestHash,
|
|
88
|
+
shipmentId: null,
|
|
89
|
+
organizationId: scope.organizationId,
|
|
90
|
+
tenantId: scope.tenantId,
|
|
91
|
+
})
|
|
92
|
+
try {
|
|
93
|
+
await em.persist(record).flush()
|
|
94
|
+
return record
|
|
95
|
+
} catch (error: unknown) {
|
|
96
|
+
if (error instanceof UniqueConstraintViolationException) return null
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function resolveShipmentIdempotency(
|
|
102
|
+
em: EntityManager,
|
|
103
|
+
claim: CarrierShipmentIdempotencyKey,
|
|
104
|
+
shipmentId: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
claim.shipmentId = shipmentId
|
|
107
|
+
await em.flush()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function releaseShipmentIdempotency(
|
|
111
|
+
em: EntityManager,
|
|
112
|
+
idempotencyKey: string,
|
|
113
|
+
providerKey: string,
|
|
114
|
+
scope: Scope,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const existing = await findShipmentIdempotencyClaim(em, idempotencyKey, providerKey, scope)
|
|
117
|
+
if (!existing) return
|
|
118
|
+
await em.remove(existing).flush()
|
|
119
|
+
}
|
|
@@ -37,11 +37,12 @@ export type CreateShipmentParams = {
|
|
|
37
37
|
receiverEmail?: string
|
|
38
38
|
targetPoint?: string
|
|
39
39
|
c2cSendingMethod?: string
|
|
40
|
+
idempotencyKey?: string
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export type CreateShipmentResult =
|
|
43
44
|
| { ok: true }
|
|
44
|
-
| { ok: false; error: string }
|
|
45
|
+
| { ok: false; error: string; code?: string }
|
|
45
46
|
|
|
46
47
|
export type FetchDropOffPointsParams = {
|
|
47
48
|
providerKey: string
|
|
@@ -126,6 +127,7 @@ export const createShipment = async (params: CreateShipmentParams): Promise<Crea
|
|
|
126
127
|
.otherwise(({ result }) => ({
|
|
127
128
|
ok: false as const,
|
|
128
129
|
error: (result as { error?: string } | null)?.error ?? 'Failed to create shipment.',
|
|
130
|
+
code: (result as { code?: string } | null)?.code,
|
|
129
131
|
}))
|
|
130
132
|
}
|
|
131
133
|
|
|
@@ -194,6 +194,9 @@ export const useShipmentWizard = (): ShipmentWizard => {
|
|
|
194
194
|
setIsFetchingRates(false)
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
const idempotencyKeyRef = React.useRef<string | null>(null)
|
|
198
|
+
const idempotencySignatureRef = React.useRef<string | null>(null)
|
|
199
|
+
|
|
197
200
|
const handleSubmit = async () => {
|
|
198
201
|
if (!selectedProvider || !selectedRate || !orderId) return
|
|
199
202
|
setIsSubmitting(true)
|
|
@@ -212,20 +215,39 @@ export const useShipmentWizard = (): ShipmentWizard => {
|
|
|
212
215
|
...(targetPoint ? { targetPoint } : {}),
|
|
213
216
|
...(c2cSendingMethod ? { c2cSendingMethod } : {}),
|
|
214
217
|
}
|
|
218
|
+
// Reuse one idempotency key per confirmed payload so a double-submit or a
|
|
219
|
+
// network-timeout retry de-duplicates server-side; regenerate it when the
|
|
220
|
+
// payload changes so an edited shipment is not rejected as a conflict.
|
|
221
|
+
const signature = JSON.stringify(payload)
|
|
222
|
+
if (idempotencyKeyRef.current === null || idempotencySignatureRef.current !== signature) {
|
|
223
|
+
idempotencyKeyRef.current = crypto.randomUUID()
|
|
224
|
+
idempotencySignatureRef.current = signature
|
|
225
|
+
}
|
|
226
|
+
const submitPayload: CreateShipmentParams = { ...payload, idempotencyKey: idempotencyKeyRef.current }
|
|
215
227
|
try {
|
|
216
228
|
const result = await runMutation({
|
|
217
|
-
operation: () => createShipment(
|
|
229
|
+
operation: () => createShipment(submitPayload),
|
|
218
230
|
context: {
|
|
219
231
|
operation: 'create',
|
|
220
232
|
resourceKind: 'shipping_carriers.shipment',
|
|
221
233
|
resourceId: orderId,
|
|
222
234
|
retryLastMutation,
|
|
223
235
|
},
|
|
224
|
-
mutationPayload:
|
|
236
|
+
mutationPayload: submitPayload as unknown as Record<string, unknown>,
|
|
225
237
|
})
|
|
226
238
|
if (result.ok) {
|
|
239
|
+
idempotencyKeyRef.current = null
|
|
240
|
+
idempotencySignatureRef.current = null
|
|
227
241
|
flash(t('shipping_carriers.create.success', 'Shipment created successfully.'), 'success')
|
|
228
242
|
router.push(backHref)
|
|
243
|
+
} else if (result.code === 'idempotency_conflict') {
|
|
244
|
+
flash(
|
|
245
|
+
t(
|
|
246
|
+
'shipping_carriers.create.error.idempotencyConflict',
|
|
247
|
+
'This shipment was already submitted. Refresh the page before retrying.',
|
|
248
|
+
),
|
|
249
|
+
'error',
|
|
250
|
+
)
|
|
229
251
|
} else {
|
|
230
252
|
flash(t('shipping_carriers.create.error.create', result.error), 'error')
|
|
231
253
|
}
|