@dainamite/cpq 0.4.0 → 0.5.0
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/CHANGELOG.md +22 -0
- package/MIGRATION.md +27 -0
- package/dist/modules/cpq/api/_helpers/handleArcError.js +2 -2
- package/dist/modules/cpq/api/_helpers/handleArcError.js.map +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/route.js +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/status/route.js +2 -2
- package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/route.js +2 -2
- package/dist/modules/cpq/api/orders/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +3 -3
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/route.js +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/route.js +2 -2
- package/dist/modules/cpq/api/quotes/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/offerings/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/pricing/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/quotes/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/specifications/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/wizards/page.js.map +1 -1
- package/dist/modules/cpq/components/CpqListView.js.map +2 -2
- package/dist/modules/cpq/services/cpqOrderService.js +9 -2
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqQuotingService.js +9 -2
- package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
- package/package.json +9 -9
- package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +8 -5
- package/src/modules/cpq/api/_helpers/handleArcError.ts +12 -12
- package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +2 -2
- package/src/modules/cpq/api/orders/[orderId]/route.ts +2 -2
- package/src/modules/cpq/api/orders/[orderId]/status/route.ts +2 -2
- package/src/modules/cpq/api/orders/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +3 -3
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/route.ts +2 -2
- package/src/modules/cpq/api/quotes/route.ts +2 -2
- package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +1 -1
- package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +1 -1
- package/src/modules/cpq/backend/cpq/offerings/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/orders/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/price-rules/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +4 -3
- package/src/modules/cpq/backend/cpq/quotes/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/specifications/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
- package/src/modules/cpq/components/CpqListView.tsx +2 -1
- package/src/modules/cpq/services/__tests__/errorBrands.test.ts +72 -0
- package/src/modules/cpq/services/cpqOrderService.ts +10 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dainamite/cpq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Dainamite CPQ — Configure, Price, Quote engine for Open Mercato (configurable products, multi-charge pricing tables, guided quoting, customer inventory, orders).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/modules/cpq/index.js",
|
|
@@ -121,14 +121,14 @@
|
|
|
121
121
|
}
|
|
122
122
|
},
|
|
123
123
|
"peerDependencies": {
|
|
124
|
-
"@mikro-orm/core": "^7.1.
|
|
125
|
-
"@mikro-orm/decorators": "^7.1.
|
|
126
|
-
"@mikro-orm/postgresql": "^7.1.
|
|
127
|
-
"@open-mercato/core": "^0.
|
|
128
|
-
"@open-mercato/events": "^0.
|
|
129
|
-
"@open-mercato/shared": "^0.
|
|
130
|
-
"@open-mercato/ui": "^0.
|
|
131
|
-
"@tanstack/react-table": "^
|
|
124
|
+
"@mikro-orm/core": "^7.1.14",
|
|
125
|
+
"@mikro-orm/decorators": "^7.1.14",
|
|
126
|
+
"@mikro-orm/postgresql": "^7.1.14",
|
|
127
|
+
"@open-mercato/core": "^0.9.0",
|
|
128
|
+
"@open-mercato/events": "^0.9.0",
|
|
129
|
+
"@open-mercato/shared": "^0.9.0",
|
|
130
|
+
"@open-mercato/ui": "^0.9.0",
|
|
131
|
+
"@tanstack/react-table": "^9.0.0",
|
|
132
132
|
"awilix": "^12.0.0",
|
|
133
133
|
"bcryptjs": "^3.0.0",
|
|
134
134
|
"lucide-react": "^0.400.0",
|
|
@@ -108,16 +108,19 @@ test.describe('TC-CPQ-002: Quote golden path (API)', () => {
|
|
|
108
108
|
expect(res.status()).toBe(400)
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
-
test('rejects fetching an unknown quote
|
|
111
|
+
test('rejects fetching an unknown quote with 404, also after a page has rendered', async ({ request }) => {
|
|
112
|
+
// Render a page first. Pages and API routes bundle @dainamite/cpq
|
|
113
|
+
// separately while sharing one DI container, so once the page bootstrap
|
|
114
|
+
// has run the route catches a QuotingError from the other copy of the
|
|
115
|
+
// class. With `instanceof` that surfaced as a 500; the brand-based
|
|
116
|
+
// isQuotingError guard must keep it a 404.
|
|
117
|
+
await request.get('/login')
|
|
112
118
|
const res = await apiRequest(
|
|
113
119
|
request,
|
|
114
120
|
'GET',
|
|
115
121
|
'/api/cpq/quotes/00000000-0000-4000-8000-000000000000',
|
|
116
122
|
{ token, data: undefined },
|
|
117
123
|
)
|
|
118
|
-
|
|
119
|
-
// build can surface 500 from MikroORM-not-found exceptions. Both prove "rejected".
|
|
120
|
-
expect(res.ok()).toBeFalsy()
|
|
121
|
-
expect(res.status()).toBeGreaterThanOrEqual(400)
|
|
124
|
+
expect(res.status()).toBe(404)
|
|
122
125
|
})
|
|
123
126
|
})
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { OptimisticLockError } from '@mikro-orm/core'
|
|
4
|
-
import {
|
|
4
|
+
import { isQuotingError } from '../../services/cpqQuotingService'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Shared error → JSON-response converter for ARC routes (XD-250).
|
|
8
8
|
*
|
|
9
9
|
* Handles three families:
|
|
10
10
|
* 1. Zod validation errors → 400 with `details` array.
|
|
11
|
-
* 2. Service-layer `QuotingError` (matched
|
|
12
|
-
* duck-
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* 2. Service-layer `QuotingError` (matched via its cross-realm brand,
|
|
12
|
+
* `isQuotingError`, with a duck-typed fallback below) → propagates
|
|
13
|
+
* `err.status`. `instanceof` is unreliable here: Next bundles this
|
|
14
|
+
* package once per server layer, so the route and the DI-resolved
|
|
15
|
+
* service can hold two distinct copies of the class — in production
|
|
16
|
+
* too, not only under hot-reload.
|
|
16
17
|
* 3. cpqInventoryService throws plain `{ status, error }` objects →
|
|
17
18
|
* propagate the embedded status.
|
|
18
19
|
* 4. Anything else → 500 + console.error with the route context.
|
|
@@ -28,7 +29,7 @@ export function handleArcError(err: unknown, ctx: string): NextResponse {
|
|
|
28
29
|
)
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
if (err
|
|
32
|
+
if (isQuotingError(err)) {
|
|
32
33
|
const body: Record<string, unknown> = { error: err.message }
|
|
33
34
|
if (err.cpqStatus) body.cpqStatus = err.cpqStatus
|
|
34
35
|
if (err.details) Object.assign(body, err.details)
|
|
@@ -52,11 +53,10 @@ export function handleArcError(err: unknown, ctx: string): NextResponse {
|
|
|
52
53
|
)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// would have surfaced if instanceof had matched.
|
|
56
|
+
// cpqInventoryService's plain throw shape `{ status, error }` (QuotingError
|
|
57
|
+
// from any bundle copy is already handled by `isQuotingError` above).
|
|
58
|
+
// Propagates `details` (object) into the response body so structured
|
|
59
|
+
// payloads survive here too.
|
|
60
60
|
if (
|
|
61
61
|
err &&
|
|
62
62
|
typeof err === 'object' &&
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { OptimisticLockError } from '@mikro-orm/core'
|
|
3
3
|
import { resolveCpqRouteContext } from '../../../context'
|
|
4
|
-
import {
|
|
4
|
+
import { isOrderError } from '../../../../services/cpqOrderService'
|
|
5
5
|
import type { DefaultCpqOrderService } from '../../../../services/cpqOrderService'
|
|
6
6
|
|
|
7
7
|
export const metadata = {
|
|
@@ -20,7 +20,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ orderId
|
|
|
20
20
|
const result = await service.activateOrder(orderId, scope)
|
|
21
21
|
return NextResponse.json(result)
|
|
22
22
|
} catch (err) {
|
|
23
|
-
if (err
|
|
23
|
+
if (isOrderError(err)) {
|
|
24
24
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
25
25
|
}
|
|
26
26
|
// XD-250 ARC: optimistic-lock collision on subscription.version when two
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { resolveCpqRouteContext } from '../../context'
|
|
3
|
-
import {
|
|
3
|
+
import { isOrderError } from '../../../services/cpqOrderService'
|
|
4
4
|
import type { DefaultCpqOrderService } from '../../../services/cpqOrderService'
|
|
5
5
|
|
|
6
6
|
export const metadata = {
|
|
@@ -19,7 +19,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ orderId:
|
|
|
19
19
|
const result = await service.getOrder(orderId, scope)
|
|
20
20
|
return NextResponse.json(result)
|
|
21
21
|
} catch (err) {
|
|
22
|
-
if (err
|
|
22
|
+
if (isOrderError(err)) {
|
|
23
23
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
24
24
|
}
|
|
25
25
|
console.error('[cpq/orders/[orderId].GET]', err)
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { resolveCpqRouteContext } from '../../../context'
|
|
4
4
|
import { cpqOrderStatusTransitionSchema } from '../../../../data/validators'
|
|
5
|
-
import {
|
|
5
|
+
import { isOrderError } from '../../../../services/cpqOrderService'
|
|
6
6
|
import type { DefaultCpqOrderService } from '../../../../services/cpqOrderService'
|
|
7
7
|
|
|
8
8
|
export const metadata = {
|
|
@@ -25,7 +25,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ orderId
|
|
|
25
25
|
if (err instanceof z.ZodError) {
|
|
26
26
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
27
27
|
}
|
|
28
|
-
if (err
|
|
28
|
+
if (isOrderError(err)) {
|
|
29
29
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
30
30
|
}
|
|
31
31
|
const errObj = err as Record<string, unknown>
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { resolveCpqRouteContext } from '../context'
|
|
4
4
|
import { cpqConvertQuoteToOrderSchema } from '../../data/validators'
|
|
5
|
-
import {
|
|
5
|
+
import { isOrderError } from '../../services/cpqOrderService'
|
|
6
6
|
import type { DefaultCpqOrderService } from '../../services/cpqOrderService'
|
|
7
7
|
|
|
8
8
|
export const metadata = {
|
|
@@ -65,7 +65,7 @@ function handleError(err: unknown, context: string) {
|
|
|
65
65
|
if (err instanceof z.ZodError) {
|
|
66
66
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
67
67
|
}
|
|
68
|
-
if (err
|
|
68
|
+
if (isOrderError(err)) {
|
|
69
69
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
70
70
|
}
|
|
71
71
|
const errObj = err as Record<string, unknown>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { resolveCpqRouteContext } from '../../../context'
|
|
3
|
-
import {
|
|
3
|
+
import { isQuotingError } from '../../../../services/cpqQuotingService'
|
|
4
4
|
import { resolveQuotingService } from '../../../resolveQuotingService'
|
|
5
5
|
|
|
6
6
|
export const metadata = {
|
|
@@ -19,7 +19,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
|
|
|
19
19
|
const result = await service.cloneQuote(quoteId, scope)
|
|
20
20
|
return NextResponse.json(result, { status: 201 })
|
|
21
21
|
} catch (err) {
|
|
22
|
-
if (err
|
|
22
|
+
if (isQuotingError(err)) {
|
|
23
23
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
24
24
|
}
|
|
25
25
|
console.error('[cpq/quotes/[quoteId]/clone.POST]', err)
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { resolveCpqRouteContext } from '../../../../context'
|
|
4
4
|
import { cpqUpdateQuoteItemSchema } from '../../../../../data/validators'
|
|
5
|
-
import {
|
|
5
|
+
import { isQuotingError } from '../../../../../services/cpqQuotingService'
|
|
6
6
|
import { resolveQuotingService } from '../../../../resolveQuotingService'
|
|
7
7
|
|
|
8
8
|
export const metadata = {
|
|
@@ -26,7 +26,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ quoteId:
|
|
|
26
26
|
if (err instanceof z.ZodError) {
|
|
27
27
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
28
28
|
}
|
|
29
|
-
if (err
|
|
29
|
+
if (isQuotingError(err)) {
|
|
30
30
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
31
31
|
}
|
|
32
32
|
console.error('[cpq/quotes/[quoteId]/items/[lineId].PUT]', err)
|
|
@@ -46,7 +46,7 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ quote
|
|
|
46
46
|
const result = await service.removeQuoteItem(quoteId, lineId, scope)
|
|
47
47
|
return NextResponse.json(result)
|
|
48
48
|
} catch (err) {
|
|
49
|
-
if (err
|
|
49
|
+
if (isQuotingError(err)) {
|
|
50
50
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
51
51
|
}
|
|
52
52
|
console.error('[cpq/quotes/[quoteId]/items/[lineId].DELETE]', err)
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { resolveCpqRouteContext } from '../../../context'
|
|
4
4
|
import { cpqRecalculateSchema } from '../../../../data/validators'
|
|
5
|
-
import {
|
|
5
|
+
import { isQuotingError } from '../../../../services/cpqQuotingService'
|
|
6
6
|
import { resolveQuotingService } from '../../../resolveQuotingService'
|
|
7
7
|
|
|
8
8
|
export const metadata = {
|
|
@@ -25,7 +25,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ quoteId
|
|
|
25
25
|
if (err instanceof z.ZodError) {
|
|
26
26
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
27
27
|
}
|
|
28
|
-
if (err
|
|
28
|
+
if (isQuotingError(err)) {
|
|
29
29
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
30
30
|
}
|
|
31
31
|
console.error('[cpq/quotes/[quoteId]/recalculate.POST]', err)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { resolveCpqRouteContext } from '../../context'
|
|
3
3
|
import { cpqUpdateQuoteSchema } from '../../../data/validators'
|
|
4
|
-
import {
|
|
4
|
+
import { isQuotingError } from '../../../services/cpqQuotingService'
|
|
5
5
|
import { resolveQuotingService } from '../../resolveQuotingService'
|
|
6
6
|
import { CpqQuoteConfiguration, CpqQuoteLineConfiguration } from '../../../data/entities'
|
|
7
7
|
import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
@@ -25,7 +25,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ quoteId:
|
|
|
25
25
|
const result = await service.getQuote(quoteId, scope)
|
|
26
26
|
return NextResponse.json(result)
|
|
27
27
|
} catch (err) {
|
|
28
|
-
if (err
|
|
28
|
+
if (isQuotingError(err)) {
|
|
29
29
|
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
30
30
|
}
|
|
31
31
|
console.error('[cpq/quotes/[quoteId].GET]', err)
|
|
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { resolveCpqRouteContext } from '../context'
|
|
4
4
|
import { cpqCreateQuoteSchema } from '../../data/validators'
|
|
5
|
-
import {
|
|
5
|
+
import { isQuotingError } from '../../services/cpqQuotingService'
|
|
6
6
|
import { resolveQuotingService } from '../resolveQuotingService'
|
|
7
7
|
import { CpqQuoteConfiguration } from '../../data/entities'
|
|
8
8
|
import { SalesQuote } from '@open-mercato/core/modules/sales/data/entities'
|
|
@@ -104,7 +104,7 @@ function handleError(err: unknown, context: string) {
|
|
|
104
104
|
if (err instanceof z.ZodError) {
|
|
105
105
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
106
106
|
}
|
|
107
|
-
if (err
|
|
107
|
+
if (isQuotingError(err)) {
|
|
108
108
|
const body: Record<string, unknown> = { error: err.message }
|
|
109
109
|
if (err.cpqStatus) body.cpqStatus = err.cpqStatus
|
|
110
110
|
return NextResponse.json(body, { status: err.status })
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
4
|
import { useRouter } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
7
7
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
8
8
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
4
|
import { useRouter } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { DataTable } from '@open-mercato/ui/backend/DataTable'
|
|
7
7
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
8
8
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
4
|
import { useRouter } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { type BulkAction } from '@open-mercato/ui/backend/DataTable'
|
|
7
7
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
8
8
|
import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
7
7
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
8
8
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
7
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
8
8
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
4
|
import { useRouter } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
7
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
8
8
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
@@ -850,9 +850,10 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
|
|
|
850
850
|
<Alert variant="warning">
|
|
851
851
|
<AlertTitle>Validation Issues</AlertTitle>
|
|
852
852
|
<AlertDescription>
|
|
853
|
-
{/*
|
|
854
|
-
|
|
855
|
-
HTML and Next 16
|
|
853
|
+
{/* Stacked <span>s rather than <ul>/<li>: before Open Mercato
|
|
854
|
+
0.8 AlertDescription rendered a <p>, where a list is invalid
|
|
855
|
+
HTML and a Next 16 hydration error. It is a <div> now, so a
|
|
856
|
+
list would be fine; the spans are kept as harmless. */}
|
|
856
857
|
<span className="text-xs space-y-0.5 block">
|
|
857
858
|
{cpqQuote.validationResult.errors.map((e, i) => (
|
|
858
859
|
<span key={i} className="block">— {e.message}</span>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
7
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
8
8
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
4
|
import { useRouter } from 'next/navigation'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
7
|
import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
|
|
8
8
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
4
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
5
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
6
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
7
7
|
import { RowActions } from '@open-mercato/ui/backend/RowActions'
|
|
8
8
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import type {
|
|
3
|
+
import type { SortingState } from '@tanstack/react-table'
|
|
4
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
4
5
|
import { DataTable, type BulkAction } from '@open-mercato/ui/backend/DataTable'
|
|
5
6
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
6
7
|
import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QuotingError / OrderError must be recognisable across duplicate copies of
|
|
3
|
+
* their module. In a production Next build the page layer and the route layer
|
|
4
|
+
* each bundle @dainamite/cpq, and the DI container is shared via globalThis,
|
|
5
|
+
* so a route handler can catch an error built from the *other* copy. With
|
|
6
|
+
* `instanceof` that silently turned a typed 404 into a 500.
|
|
7
|
+
*
|
|
8
|
+
* `jest.isolateModules` loads a second, independent copy of each module —
|
|
9
|
+
* the same condition the bundler creates.
|
|
10
|
+
*/
|
|
11
|
+
import { QuotingError, isQuotingError } from '../cpqQuotingService'
|
|
12
|
+
import { OrderError, isOrderError } from '../cpqOrderService'
|
|
13
|
+
|
|
14
|
+
type QuotingModule = typeof import('../cpqQuotingService')
|
|
15
|
+
type OrderModule = typeof import('../cpqOrderService')
|
|
16
|
+
|
|
17
|
+
function loadIsolated<T>(load: () => T): T {
|
|
18
|
+
let mod: T | undefined
|
|
19
|
+
jest.isolateModules(() => {
|
|
20
|
+
mod = load()
|
|
21
|
+
})
|
|
22
|
+
if (!mod) throw new Error('[internal] isolated module failed to load')
|
|
23
|
+
return mod
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('isQuotingError', () => {
|
|
27
|
+
it('recognises an error thrown by a separate copy of the module', () => {
|
|
28
|
+
const other = loadIsolated<QuotingModule>(() => require('../cpqQuotingService'))
|
|
29
|
+
const err = new other.QuotingError(404, 'Quote not found')
|
|
30
|
+
|
|
31
|
+
// The precondition the guard exists for: the class identities differ.
|
|
32
|
+
expect(other.QuotingError).not.toBe(QuotingError)
|
|
33
|
+
expect(err instanceof QuotingError).toBe(false)
|
|
34
|
+
|
|
35
|
+
expect(isQuotingError(err)).toBe(true)
|
|
36
|
+
if (isQuotingError(err)) expect(err.status).toBe(404)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('accepts a same-copy error', () => {
|
|
40
|
+
expect(isQuotingError(new QuotingError(409, 'conflict'))).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it.each([
|
|
44
|
+
['a plain Error', new Error('x')],
|
|
45
|
+
['an OrderError', new OrderError(404, 'x')],
|
|
46
|
+
['a look-alike with the same name and status', Object.assign(new Error('x'), { name: 'QuotingError', status: 404 })],
|
|
47
|
+
['null', null],
|
|
48
|
+
['a string', 'QuotingError'],
|
|
49
|
+
])('rejects %s', (_label, value) => {
|
|
50
|
+
expect(isQuotingError(value)).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('isOrderError', () => {
|
|
55
|
+
it('recognises an error thrown by a separate copy of the module', () => {
|
|
56
|
+
const other = loadIsolated<OrderModule>(() => require('../cpqOrderService'))
|
|
57
|
+
const err = new other.OrderError(404, 'Order not found')
|
|
58
|
+
|
|
59
|
+
expect(other.OrderError).not.toBe(OrderError)
|
|
60
|
+
expect(err instanceof OrderError).toBe(false)
|
|
61
|
+
|
|
62
|
+
expect(isOrderError(err)).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it.each([
|
|
66
|
+
['a plain Error', new Error('x')],
|
|
67
|
+
['a QuotingError', new QuotingError(404, 'x')],
|
|
68
|
+
['undefined', undefined],
|
|
69
|
+
])('rejects %s', (_label, value) => {
|
|
70
|
+
expect(isOrderError(value)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -1045,7 +1045,13 @@ function pickArcSourceFromConfig(
|
|
|
1045
1045
|
|
|
1046
1046
|
// ─── Error class ──────────────────────────────────────────────────
|
|
1047
1047
|
|
|
1048
|
+
// Cross-realm brand — see QUOTING_ERROR_BRAND in cpqQuotingService.ts for why
|
|
1049
|
+
// route handlers must not rely on `instanceof`. Use `isOrderError`.
|
|
1050
|
+
const ORDER_ERROR_BRAND = Symbol.for('@dainamite/cpq/OrderError')
|
|
1051
|
+
|
|
1048
1052
|
export class OrderError extends Error {
|
|
1053
|
+
readonly [ORDER_ERROR_BRAND] = true
|
|
1054
|
+
|
|
1049
1055
|
constructor(
|
|
1050
1056
|
public status: number,
|
|
1051
1057
|
message: string,
|
|
@@ -1055,6 +1061,10 @@ export class OrderError extends Error {
|
|
|
1055
1061
|
}
|
|
1056
1062
|
}
|
|
1057
1063
|
|
|
1064
|
+
export function isOrderError(err: unknown): err is OrderError {
|
|
1065
|
+
return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[ORDER_ERROR_BRAND] === true
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1058
1068
|
// ─── ARC helpers (XD-250) ─────────────────────────────────────────
|
|
1059
1069
|
|
|
1060
1070
|
/**
|
|
@@ -1387,7 +1387,17 @@ function emptyPricingSummary(currencyCode: string): Record<string, unknown> {
|
|
|
1387
1387
|
|
|
1388
1388
|
// ─── Error class ──────────────────────────────────────────────────
|
|
1389
1389
|
|
|
1390
|
+
// Cross-realm brand. Next bundles this package once per server layer (page
|
|
1391
|
+
// SSR vs route handler), and the DI container is shared through globalThis,
|
|
1392
|
+
// so a route handler can catch a QuotingError built from the *other* copy of
|
|
1393
|
+
// this class — `instanceof` is then false and a typed 404/409 turns into a
|
|
1394
|
+
// 500. `Symbol.for` resolves to the same symbol in every copy. Same approach
|
|
1395
|
+
// as `isCrudHttpError` in @open-mercato/shared. Use `isQuotingError`.
|
|
1396
|
+
const QUOTING_ERROR_BRAND = Symbol.for('@dainamite/cpq/QuotingError')
|
|
1397
|
+
|
|
1390
1398
|
export class QuotingError extends Error {
|
|
1399
|
+
readonly [QUOTING_ERROR_BRAND] = true
|
|
1400
|
+
|
|
1391
1401
|
constructor(
|
|
1392
1402
|
public status: number,
|
|
1393
1403
|
message: string,
|
|
@@ -1406,6 +1416,10 @@ export class QuotingError extends Error {
|
|
|
1406
1416
|
}
|
|
1407
1417
|
}
|
|
1408
1418
|
|
|
1419
|
+
export function isQuotingError(err: unknown): err is QuotingError {
|
|
1420
|
+
return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[QUOTING_ERROR_BRAND] === true
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1409
1423
|
// ─── ARC orchestration (XD-250) ────────────────────────────────────
|
|
1410
1424
|
//
|
|
1411
1425
|
// All ARC user-journey logic lives on the CPQ Quote module:
|