@dainamite/cpq 0.4.0 → 0.6.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 +70 -0
- package/MIGRATION.md +101 -0
- package/dist/modules/cpq/acl.js +1 -0
- package/dist/modules/cpq/acl.js.map +2 -2
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -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/_helpers/quoteMutationGuard.js +23 -0
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
- 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 +19 -3
- 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 +49 -4
- 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]/restore/route.js +54 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +45 -5
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +34 -4
- 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/[quoteId]/status/route.js +33 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/price/route.js +43 -3
- package/dist/modules/cpq/api/quotes/price/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/inventory/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +22 -8
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +89 -232
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.meta.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]/_components/PriceAdjustmentDialog.js +205 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +144 -221
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
- package/dist/modules/cpq/backend/cpq/quotes/page.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.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/ce.js +8 -0
- package/dist/modules/cpq/ce.js.map +2 -2
- package/dist/modules/cpq/cli.js +34 -1
- package/dist/modules/cpq/cli.js.map +2 -2
- package/dist/modules/cpq/commands/price-adjustments.js +107 -0
- package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
- package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js +154 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
- package/dist/modules/cpq/components/CpqListView.js.map +2 -2
- package/dist/modules/cpq/components/PriceWaterfall.js +66 -0
- package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js +117 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +97 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/price-adjustments.js +70 -0
- package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/data/validators.js +4 -0
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/di.js +6 -0
- package/dist/modules/cpq/di.js.map +2 -2
- package/dist/modules/cpq/events.js +47 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/index.js +1 -1
- package/dist/modules/cpq/index.js.map +2 -2
- package/dist/modules/cpq/lib/auditActor.js +10 -0
- package/dist/modules/cpq/lib/auditActor.js.map +7 -0
- package/dist/modules/cpq/lib/formatMoney.js +7 -0
- package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
- package/dist/modules/cpq/lib/quotePermissions.js +13 -0
- package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
- package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +34 -30
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +7 -0
- package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
- package/dist/modules/cpq/services/cpqQuotingService.js +339 -111
- package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
- package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
- package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/dist/modules/cpq/setup.js +6 -0
- package/dist/modules/cpq/setup.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/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
- package/src/modules/cpq/acl.ts +1 -0
- package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
- package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
- package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
- package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
- package/src/modules/cpq/api/_helpers/handleArcError.ts +12 -12
- package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
- package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
- package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +2 -2
- package/src/modules/cpq/api/orders/[orderId]/route.ts +20 -2
- package/src/modules/cpq/api/orders/[orderId]/status/route.ts +2 -2
- package/src/modules/cpq/api/orders/route.ts +53 -3
- package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +51 -4
- package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +42 -3
- package/src/modules/cpq/api/quotes/[quoteId]/route.ts +2 -2
- package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
- package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
- package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
- package/src/modules/cpq/api/quotes/price/route.ts +28 -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/inventory/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +32 -10
- package/src/modules/cpq/backend/cpq/offerings/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +60 -269
- package/src/modules/cpq/backend/cpq/orders/page.meta.ts +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]/_components/PriceAdjustmentDialog.tsx +260 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +178 -266
- package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
- 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/ce.ts +8 -0
- package/src/modules/cpq/cli.ts +48 -1
- package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
- package/src/modules/cpq/commands/price-adjustments.ts +90 -0
- package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
- package/src/modules/cpq/components/ChargeBreakdownTable.tsx +201 -0
- package/src/modules/cpq/components/CpqListView.tsx +2 -1
- package/src/modules/cpq/components/PriceWaterfall.tsx +94 -0
- package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
- package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
- package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
- package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
- package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
- package/src/modules/cpq/data/entities.ts +83 -0
- package/src/modules/cpq/data/price-adjustments.ts +67 -0
- package/src/modules/cpq/data/validators.ts +3 -0
- package/src/modules/cpq/di.ts +6 -0
- package/src/modules/cpq/events.ts +53 -2
- package/src/modules/cpq/index.ts +1 -1
- package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
- package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
- package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
- package/src/modules/cpq/lib/auditActor.ts +13 -0
- package/src/modules/cpq/lib/formatMoney.ts +4 -0
- package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
- package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
- package/src/modules/cpq/lib/quotePermissions.ts +22 -0
- package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
- package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
- package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
- package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
- package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
- package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
- package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
- package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
- package/src/modules/cpq/services/__tests__/errorBrands.test.ts +72 -0
- package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
- package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
- package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
- package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
- package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
- package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
- package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
- package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
- package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
- package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
- package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
- package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
- package/src/modules/cpq/services/cpqOrderService.ts +32 -28
- package/src/modules/cpq/services/cpqPricingService.ts +8 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +388 -125
- package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
- package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
- package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
- package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
- package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
- package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
- package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
- package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
- package/src/modules/cpq/services/types.ts +34 -1
- package/src/modules/cpq/setup.ts +7 -1
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { DefaultCpqQuotingService } from '../cpqQuotingService'
|
|
2
2
|
import { createMockEm, TEST_SCOPE as SCOPE } from './_helpers/mockEm'
|
|
3
|
+
import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
4
|
+
import {
|
|
5
|
+
CpqProductOffering,
|
|
6
|
+
CpqQuoteConfiguration,
|
|
7
|
+
CpqQuoteLineConfiguration,
|
|
8
|
+
} from '../../data/entities'
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* XD-250 ARC unit tests for cpqQuotingService — focused on the
|
|
@@ -141,3 +147,234 @@ describe('transitionStatus → validateArcQuote hook', () => {
|
|
|
141
147
|
expect(em.findOne).toHaveBeenCalledTimes(4)
|
|
142
148
|
})
|
|
143
149
|
})
|
|
150
|
+
|
|
151
|
+
// ─── Amend mirror-line pricing on recalculation ──────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
function makePricedService() {
|
|
155
|
+
const em = Object.assign(createMockEm(), {
|
|
156
|
+
assign: jest.fn((entity: Record<string, unknown>, updates: Record<string, unknown>) =>
|
|
157
|
+
Object.assign(entity, updates),
|
|
158
|
+
),
|
|
159
|
+
fork: jest.fn(),
|
|
160
|
+
})
|
|
161
|
+
em.fork.mockReturnValue(em)
|
|
162
|
+
const pricingService = { resolveProductCharges: jest.fn() }
|
|
163
|
+
const validationService = {
|
|
164
|
+
validateOfferingConfiguration: jest.fn().mockResolvedValue({ valid: true, errors: [] }),
|
|
165
|
+
validateRelationships: jest.fn().mockResolvedValue([]),
|
|
166
|
+
}
|
|
167
|
+
const service = new DefaultCpqQuotingService({
|
|
168
|
+
em: em as never,
|
|
169
|
+
cpqPricingService: pricingService as never,
|
|
170
|
+
cpqValidationService: validationService as never,
|
|
171
|
+
cpqProductService: {} as never,
|
|
172
|
+
salesDocumentNumberGenerator: {} as never,
|
|
173
|
+
})
|
|
174
|
+
return { em, pricingService, service }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mrcCharge(price: number) {
|
|
178
|
+
return {
|
|
179
|
+
chargeCode: 'monthly_port_fee',
|
|
180
|
+
chargeName: 'Monthly Port Fee',
|
|
181
|
+
chargeType: 'mrc',
|
|
182
|
+
pricingMethod: 'flat',
|
|
183
|
+
unitPrice: price,
|
|
184
|
+
quantity: 1,
|
|
185
|
+
totalPrice: price,
|
|
186
|
+
currencyCode: 'USD',
|
|
187
|
+
adjustments: [],
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function amendFixture(configuration: Record<string, unknown>) {
|
|
192
|
+
const cpqConfig = { ...ARC_QUOTE_BASE, quoteType: 'amend', organizationId: SCOPE.organizationId, tenantId: SCOPE.tenantId }
|
|
193
|
+
const lineConfig = {
|
|
194
|
+
id: 'lc-1',
|
|
195
|
+
quoteLineId: 'ql-1',
|
|
196
|
+
quoteConfigurationId: 'cpq-1',
|
|
197
|
+
offeringId: 'off-1',
|
|
198
|
+
productId: 'prod-1',
|
|
199
|
+
specId: null,
|
|
200
|
+
action: 'modify',
|
|
201
|
+
parentLineId: null,
|
|
202
|
+
startDate: null,
|
|
203
|
+
termMonths: null,
|
|
204
|
+
endDate: null,
|
|
205
|
+
configuration,
|
|
206
|
+
quantity: 1,
|
|
207
|
+
charges: [mrcCharge(100)],
|
|
208
|
+
nrcTotal: '0',
|
|
209
|
+
mrcTotal: '100',
|
|
210
|
+
usageEstimates: null,
|
|
211
|
+
usageTotalEstimated: null,
|
|
212
|
+
validationErrors: null,
|
|
213
|
+
isConfigured: true,
|
|
214
|
+
targetSubscriptionId: 'sub-1',
|
|
215
|
+
sourceSubscriptionItemId: 'item-1',
|
|
216
|
+
}
|
|
217
|
+
return { cpqConfig, lineConfig }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function routeEm(
|
|
221
|
+
em: ReturnType<typeof makePricedService>['em'],
|
|
222
|
+
cpqConfig: Record<string, unknown>,
|
|
223
|
+
lineConfig: Record<string, unknown>,
|
|
224
|
+
) {
|
|
225
|
+
em.findOne.mockImplementation(async (entity: unknown) => {
|
|
226
|
+
if (entity === CpqQuoteConfiguration) return cpqConfig
|
|
227
|
+
if (entity === SalesQuote) return { id: 'sq-1', currencyCode: 'USD', quoteNumber: 'Q-001' }
|
|
228
|
+
if (entity === SalesQuoteLine) return { id: 'ql-1' }
|
|
229
|
+
if (entity === CpqProductOffering) return { id: 'off-1', name: 'GIX Access Port', offeringType: 'simple' }
|
|
230
|
+
if (entity === CpqQuoteLineConfiguration) return lineConfig
|
|
231
|
+
return null
|
|
232
|
+
})
|
|
233
|
+
em.find.mockImplementation(async (entity: unknown) => {
|
|
234
|
+
if (entity === CpqQuoteLineConfiguration) return [lineConfig]
|
|
235
|
+
return []
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
describe('recalculate → Amend mirror lines', () => {
|
|
240
|
+
it('re-prices a legacy Amend mirror line (created before the frozen marker) at the current standard price', async () => {
|
|
241
|
+
const { em, pricingService, service } = makePricedService()
|
|
242
|
+
const { cpqConfig, lineConfig } = amendFixture({
|
|
243
|
+
port_size: '1G',
|
|
244
|
+
_arcMirroredFromItemId: 'item-1',
|
|
245
|
+
_arcMirroredName: 'GIX Access Port',
|
|
246
|
+
})
|
|
247
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
248
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
|
|
249
|
+
|
|
250
|
+
await service.recalculate('cpq-1', { save: true }, SCOPE)
|
|
251
|
+
|
|
252
|
+
expect(pricingService.resolveProductCharges).toHaveBeenCalledTimes(1)
|
|
253
|
+
expect(lineConfig.mrcTotal).toBe('120')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('keeps a frozen Amend mirror line at its stored price without calling the pricing engine', async () => {
|
|
257
|
+
const { em, pricingService, service } = makePricedService()
|
|
258
|
+
const { cpqConfig, lineConfig } = amendFixture({
|
|
259
|
+
port_size: '1G',
|
|
260
|
+
_arcMirroredFromItemId: 'item-1',
|
|
261
|
+
_arcMirroredName: 'GIX Access Port',
|
|
262
|
+
_arcPriceFrozen: true,
|
|
263
|
+
})
|
|
264
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
265
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
|
|
266
|
+
|
|
267
|
+
const result = await service.recalculate('cpq-1', { save: true }, SCOPE)
|
|
268
|
+
|
|
269
|
+
expect(pricingService.resolveProductCharges).not.toHaveBeenCalled()
|
|
270
|
+
expect(lineConfig.mrcTotal).toBe('100')
|
|
271
|
+
expect(result.lines[0].mrcTotal).toBe(100)
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
describe('updateQuoteItem → frozen Amend mirror lines', () => {
|
|
276
|
+
const FROZEN_CFG = {
|
|
277
|
+
port_size: '1G',
|
|
278
|
+
_arcMirroredFromItemId: 'item-1',
|
|
279
|
+
_arcMirroredName: 'GIX Access Port',
|
|
280
|
+
_arcPriceFrozen: true,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
it('stays frozen when the UI resubmits an unchanged configuration', async () => {
|
|
284
|
+
const { em, pricingService, service } = makePricedService()
|
|
285
|
+
const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
|
|
286
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
287
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
|
|
288
|
+
|
|
289
|
+
await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '1G' }, quantity: 1 }, SCOPE)
|
|
290
|
+
|
|
291
|
+
expect(pricingService.resolveProductCharges).not.toHaveBeenCalled()
|
|
292
|
+
expect(lineConfig.configuration).toMatchObject({ _arcPriceFrozen: true, _arcMirroredFromItemId: 'item-1' })
|
|
293
|
+
expect(lineConfig.mrcTotal).toBe('100')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('unfreezes and re-prices when the configuration changes', async () => {
|
|
297
|
+
const { em, pricingService, service } = makePricedService()
|
|
298
|
+
const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
|
|
299
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
300
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(150)])
|
|
301
|
+
|
|
302
|
+
await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '10G' } }, SCOPE)
|
|
303
|
+
|
|
304
|
+
expect(pricingService.resolveProductCharges).toHaveBeenCalled()
|
|
305
|
+
expect((lineConfig.configuration as Record<string, unknown>)._arcPriceFrozen).toBeUndefined()
|
|
306
|
+
expect(lineConfig.configuration).toMatchObject({ port_size: '10G', _arcMirroredFromItemId: 'item-1' })
|
|
307
|
+
expect(lineConfig.mrcTotal).toBe('150')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('unfreezes and re-prices when the quantity changes', async () => {
|
|
311
|
+
const { em, pricingService, service } = makePricedService()
|
|
312
|
+
const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
|
|
313
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
314
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(110)])
|
|
315
|
+
|
|
316
|
+
await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '1G' }, quantity: 2 }, SCOPE)
|
|
317
|
+
|
|
318
|
+
expect(pricingService.resolveProductCharges).toHaveBeenCalled()
|
|
319
|
+
expect(lineConfig.mrcTotal).toBe('220')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('unfreezes and marks the line re-priced when the UI echoes the stored markers with a changed attribute', async () => {
|
|
323
|
+
const { em, pricingService, service } = makePricedService()
|
|
324
|
+
const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
|
|
325
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
326
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(150)])
|
|
327
|
+
|
|
328
|
+
// The quote page sends back the configuration it loaded — markers included.
|
|
329
|
+
await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { ...FROZEN_CFG, port_size: '10G' }, quantity: 1 }, SCOPE)
|
|
330
|
+
|
|
331
|
+
const stored = lineConfig.configuration as Record<string, unknown>
|
|
332
|
+
expect(pricingService.resolveProductCharges).toHaveBeenCalled()
|
|
333
|
+
expect(stored._arcPriceFrozen).toBeUndefined()
|
|
334
|
+
expect(stored._arcRepriced).toBe(true)
|
|
335
|
+
expect(stored._arcMirroredFromItemId).toBe('item-1')
|
|
336
|
+
expect(lineConfig.mrcTotal).toBe('150')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('stays frozen when the UI echoes the stored markers unchanged', async () => {
|
|
340
|
+
const { em, pricingService, service } = makePricedService()
|
|
341
|
+
const { cpqConfig, lineConfig } = amendFixture({ ...FROZEN_CFG })
|
|
342
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
343
|
+
|
|
344
|
+
await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { ...FROZEN_CFG }, quantity: 1 }, SCOPE)
|
|
345
|
+
|
|
346
|
+
expect(pricingService.resolveProductCharges).not.toHaveBeenCalled()
|
|
347
|
+
expect((lineConfig.configuration as Record<string, unknown>)._arcRepriced).toBeUndefined()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('never lets a client set the freeze marker', async () => {
|
|
351
|
+
const { em, pricingService, service } = makePricedService()
|
|
352
|
+
const { cpqConfig, lineConfig } = amendFixture({ port_size: '1G', _arcMirroredFromItemId: 'item-1', _arcMirroredName: 'GIX Access Port' })
|
|
353
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
354
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
|
|
355
|
+
|
|
356
|
+
await service.updateQuoteItem('cpq-1', 'ql-1', { configuration: { port_size: '1G', _arcPriceFrozen: true } }, SCOPE)
|
|
357
|
+
|
|
358
|
+
expect(pricingService.resolveProductCharges).toHaveBeenCalled()
|
|
359
|
+
expect((lineConfig.configuration as Record<string, unknown>)._arcPriceFrozen).toBeUndefined()
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
describe('recalculate → freeze marker outside Amend mirror lines', () => {
|
|
364
|
+
it('ignores the freeze marker on a line of a new (non-ARC) quote', async () => {
|
|
365
|
+
const { em, pricingService, service } = makePricedService()
|
|
366
|
+
const { cpqConfig, lineConfig } = amendFixture({
|
|
367
|
+
port_size: '1G',
|
|
368
|
+
_arcMirroredFromItemId: 'item-1',
|
|
369
|
+
_arcPriceFrozen: true,
|
|
370
|
+
})
|
|
371
|
+
cpqConfig.quoteType = 'new'
|
|
372
|
+
routeEm(em, cpqConfig, lineConfig)
|
|
373
|
+
pricingService.resolveProductCharges.mockResolvedValue([mrcCharge(120)])
|
|
374
|
+
|
|
375
|
+
await service.recalculate('cpq-1', { save: true }, SCOPE)
|
|
376
|
+
|
|
377
|
+
expect(pricingService.resolveProductCharges).toHaveBeenCalledTimes(1)
|
|
378
|
+
expect(lineConfig.mrcTotal).toBe('120')
|
|
379
|
+
})
|
|
380
|
+
})
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { LockMode } from '@mikro-orm/core'
|
|
1
2
|
import { DefaultCpqQuotingService, QuotingError } from '../cpqQuotingService'
|
|
2
3
|
import type { TenantScope } from '../types'
|
|
3
4
|
|
|
4
5
|
interface MockEm {
|
|
6
|
+
transactional: jest.MockedFunction<(callback: (em: MockEm) => Promise<unknown>) => Promise<unknown>>
|
|
5
7
|
findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown>>
|
|
6
8
|
find: jest.MockedFunction<
|
|
7
9
|
(entity: unknown, where: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown[]>
|
|
@@ -32,8 +34,9 @@ interface MockNumberGenerator {
|
|
|
32
34
|
|
|
33
35
|
function createMockEm(): MockEm {
|
|
34
36
|
const em: MockEm = {
|
|
37
|
+
transactional: jest.fn(),
|
|
35
38
|
findOne: jest.fn(),
|
|
36
|
-
find: jest.fn(),
|
|
39
|
+
find: jest.fn().mockResolvedValue([]),
|
|
37
40
|
create: jest.fn().mockImplementation((_e, data: Record<string, unknown>) => ({ ...data, id: data.id ?? 'gen-id' })),
|
|
38
41
|
persist: jest.fn(),
|
|
39
42
|
assign: jest.fn().mockImplementation((entity: Record<string, unknown>, updates: Record<string, unknown>) => {
|
|
@@ -43,6 +46,7 @@ function createMockEm(): MockEm {
|
|
|
43
46
|
flush: jest.fn().mockResolvedValue(undefined),
|
|
44
47
|
fork: jest.fn(),
|
|
45
48
|
}
|
|
49
|
+
em.transactional.mockImplementation((callback) => callback(em))
|
|
46
50
|
em.fork.mockReturnValue(em)
|
|
47
51
|
return em
|
|
48
52
|
}
|
|
@@ -584,7 +588,7 @@ describe('DefaultCpqQuotingService.recalculate', () => {
|
|
|
584
588
|
expect(em.flush).not.toHaveBeenCalled()
|
|
585
589
|
})
|
|
586
590
|
|
|
587
|
-
it('
|
|
591
|
+
it('locks the quote in a transaction when save=true', async () => {
|
|
588
592
|
const { em, service } = makeService()
|
|
589
593
|
em.findOne
|
|
590
594
|
.mockResolvedValueOnce({
|
|
@@ -604,7 +608,9 @@ describe('DefaultCpqQuotingService.recalculate', () => {
|
|
|
604
608
|
|
|
605
609
|
await service.recalculate('cpq-1', { save: true }, SCOPE)
|
|
606
610
|
|
|
607
|
-
expect(em.
|
|
611
|
+
expect(em.transactional).toHaveBeenCalledTimes(1)
|
|
612
|
+
expect(em.fork).toHaveBeenCalledWith({ clear: true, useContext: false })
|
|
613
|
+
expect(em.findOne.mock.calls[0][2]).toMatchObject({ lockMode: LockMode.PESSIMISTIC_WRITE })
|
|
608
614
|
})
|
|
609
615
|
|
|
610
616
|
it('marks status "new" when no lines exist (auto-managed status)', async () => {
|
|
@@ -655,4 +661,53 @@ describe('DefaultCpqQuotingService.recalculate', () => {
|
|
|
655
661
|
|
|
656
662
|
expect(cpqConfig.cpqStatus).toBe('with_customer') // unchanged
|
|
657
663
|
})
|
|
664
|
+
|
|
665
|
+
it.each(['accepted', 'rejected', 'cancelled'])(
|
|
666
|
+
'rejects a saved recalculation on a %s (terminal) quote with 409 and persists nothing',
|
|
667
|
+
async (cpqStatus) => {
|
|
668
|
+
const { em, service } = makeService()
|
|
669
|
+
em.findOne.mockResolvedValueOnce({
|
|
670
|
+
id: 'cpq-1',
|
|
671
|
+
quoteId: 'sq-1',
|
|
672
|
+
cpqStatus,
|
|
673
|
+
customerId: 'cust',
|
|
674
|
+
currencyCode: 'USD',
|
|
675
|
+
version: 1,
|
|
676
|
+
parentQuoteId: null,
|
|
677
|
+
validationResult: null,
|
|
678
|
+
pricingSummary: null,
|
|
679
|
+
quoteContext: null,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
await expect(service.recalculate('cpq-1', { save: true }, SCOPE)).rejects.toMatchObject({
|
|
683
|
+
status: 409,
|
|
684
|
+
message: `Cannot recalculate quote in '${cpqStatus}' status`,
|
|
685
|
+
})
|
|
686
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
687
|
+
},
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
it('still allows a read-only (save=false) recalculation on an accepted quote', async () => {
|
|
691
|
+
const { em, service } = makeService()
|
|
692
|
+
em.findOne
|
|
693
|
+
.mockResolvedValueOnce({
|
|
694
|
+
id: 'cpq-1',
|
|
695
|
+
quoteId: 'sq-1',
|
|
696
|
+
cpqStatus: 'accepted',
|
|
697
|
+
customerId: 'cust',
|
|
698
|
+
currencyCode: 'USD',
|
|
699
|
+
version: 1,
|
|
700
|
+
parentQuoteId: null,
|
|
701
|
+
validationResult: null,
|
|
702
|
+
pricingSummary: null,
|
|
703
|
+
quoteContext: null,
|
|
704
|
+
})
|
|
705
|
+
.mockResolvedValueOnce({ id: 'sq-1', currencyCode: 'USD', quoteNumber: 'Q-0001' })
|
|
706
|
+
em.find.mockResolvedValueOnce([])
|
|
707
|
+
|
|
708
|
+
const result = await service.recalculate('cpq-1', { save: false }, SCOPE)
|
|
709
|
+
|
|
710
|
+
expect(result.cpqStatus).toBe('accepted')
|
|
711
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
712
|
+
})
|
|
658
713
|
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { summarizeOrderPricing } from '../orderPricingSummary'
|
|
2
|
+
|
|
3
|
+
const charge = (chargeType: 'nrc' | 'mrc', list: number, offer: number, standard = offer) => ({
|
|
4
|
+
chargeCode: `${chargeType}-fee`,
|
|
5
|
+
chargeName: `${chargeType} fee`,
|
|
6
|
+
chargeType,
|
|
7
|
+
pricingMethod: 'flat',
|
|
8
|
+
unitPrice: offer,
|
|
9
|
+
quantity: 1,
|
|
10
|
+
totalPrice: offer,
|
|
11
|
+
listUnitPrice: list,
|
|
12
|
+
listTotalPrice: list,
|
|
13
|
+
standardUnitPrice: standard,
|
|
14
|
+
standardTotalPrice: standard,
|
|
15
|
+
currencyCode: 'USD',
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('summarizeOrderPricing', () => {
|
|
19
|
+
it('reproduces the quote figures: list totals, deal value over the term and total depth', () => {
|
|
20
|
+
const summary = summarizeOrderPricing([
|
|
21
|
+
{ lineId: 'l1', quantity: 1, termMonths: 12, configuration: {}, charges: [charge('nrc', 890, 0), charge('mrc', 1200, 1050)] },
|
|
22
|
+
], 'new', 'USD')
|
|
23
|
+
|
|
24
|
+
expect(summary.listNrcTotal).toBe(890)
|
|
25
|
+
expect(summary.listMrcTotal).toBe(1200)
|
|
26
|
+
expect(summary.dealValue).toEqual({ list: 890 + 1200 * 12, standard: 1050 * 12, offer: 1050 * 12 })
|
|
27
|
+
expect(summary.discountDepth.total).toBeCloseTo(1 - 12600 / 15290)
|
|
28
|
+
expect(summary).not.toHaveProperty('snapshotHash')
|
|
29
|
+
expect(summary).not.toHaveProperty('hasTimeBasedAdjustments')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('leaves ARC mirror lines out of deal value, as on the quote', () => {
|
|
33
|
+
const summary = summarizeOrderPricing([
|
|
34
|
+
{ lineId: 'kept', quantity: 1, termMonths: 12, configuration: { _arcMirroredFromItemId: 'item-1' }, charges: [charge('mrc', 400, 400)] },
|
|
35
|
+
{ lineId: 'new', quantity: 1, termMonths: 12, configuration: {}, charges: [charge('mrc', 1200, 1050)] },
|
|
36
|
+
], 'amend', 'USD')
|
|
37
|
+
|
|
38
|
+
expect(summary.dealValue.offer).toBe(1050 * 12)
|
|
39
|
+
expect(summary.listMrcTotal).toBe(1600)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('tolerates legacy stored charges without code, type or waterfall fields', () => {
|
|
43
|
+
const summary = summarizeOrderPricing([
|
|
44
|
+
{ lineId: 'l1', quantity: 2, termMonths: null, configuration: null, charges: [{ unitPrice: 50, totalPrice: 50 }] },
|
|
45
|
+
], 'new', 'USD')
|
|
46
|
+
|
|
47
|
+
expect(summary.listMrcTotal).toBe(100)
|
|
48
|
+
expect(summary.dealValue.offer).toBe(50 * 2 * 12)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { computeWaterfallSummary, type SnapshotLine } from '../priceAdjustments/pricingSnapshot'
|
|
2
|
+
import { resolveManualAdjustments } from '../priceAdjustments/resolveManualAdjustments'
|
|
3
|
+
import type { PriceAdjustment } from '../../data/price-adjustments'
|
|
4
|
+
const line: SnapshotLine = {
|
|
5
|
+
lineId: 'line', quantity: 2, termMonths: 3,
|
|
6
|
+
charges: [{ chargeCode: 'mrc', chargeName: 'MRC', chargeType: 'mrc', pricingMethod: 'flat', currencyCode: 'USD', quantity: 1,
|
|
7
|
+
listUnitPrice: 100, listTotalPrice: 100, standardUnitPrice: 90, standardTotalPrice: 90, unitPrice: 80, totalPrice: 80,
|
|
8
|
+
adjustments: [
|
|
9
|
+
{ origin: 'rule', ruleCode: 'rule', ruleName: 'Rule', ruleType: 'discount_absolute', value: 10, unitPriceBefore: 100, unitPriceAfter: 90, delta: -10 },
|
|
10
|
+
{ origin: 'manual', ruleCode: 'manual', ruleName: 'Manual', ruleType: 'discount_absolute', value: 10, unitPriceBefore: 90, unitPriceAfter: 80, delta: -10 },
|
|
11
|
+
] }],
|
|
12
|
+
}
|
|
13
|
+
const row: PriceAdjustment = { id: 'a', quoteLineId: 'line', level: 'charge', chargeCode: 'mrc', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
|
|
14
|
+
it('includes line quantity, term horizon and separate rule/manual totals', () => {
|
|
15
|
+
const summary = computeWaterfallSummary([line], [row], 'USD')
|
|
16
|
+
expect(summary).toMatchObject({ listMrcTotal: 200, standardMrcTotal: 180, ruleDiscountTotal: 20, manualDiscountTotal: 20, dealValue: { list: 600, standard: 540, offer: 480 } })
|
|
17
|
+
expect(summary.discountDepth.manual).toBeCloseTo(1 - 480 / 540)
|
|
18
|
+
expect(summary.discountDepth.total).toBeCloseTo(0.2)
|
|
19
|
+
})
|
|
20
|
+
it('counts NRC once and excludes usage even over a long horizon', () => {
|
|
21
|
+
const nrc = { ...line.charges[0], chargeType: 'nrc' as const }
|
|
22
|
+
const usage = { ...line.charges[0], chargeType: 'usage' as const }
|
|
23
|
+
expect(computeWaterfallSummary([{ ...line, termMonths: 24, charges: [nrc, usage] }], [], 'USD').dealValue).toEqual({ list: 200, standard: 180, offer: 160 })
|
|
24
|
+
})
|
|
25
|
+
it('uses 12 months without a term; excludes unchanged ARC mirrors from deal value', () => {
|
|
26
|
+
expect(computeWaterfallSummary([{ ...line, termMonths: null }], [], 'USD').dealValue.list).toBe(2400)
|
|
27
|
+
expect(computeWaterfallSummary([{ ...line, includeInDeal: false }], [], 'USD').dealValue).toEqual({ list: 0, standard: 0, offer: 0 })
|
|
28
|
+
})
|
|
29
|
+
it('represents zero baselines by null and rule markups by negative depth', () => {
|
|
30
|
+
expect(computeWaterfallSummary([], [], 'USD').discountDepth).toEqual({ rule: null, manual: null, total: null, maxLineManual: null })
|
|
31
|
+
const marked = { ...line.charges[0], standardTotalPrice: 110, totalPrice: 110 }
|
|
32
|
+
expect(computeWaterfallSummary([{ ...line, charges: [marked] }], [], 'USD').discountDepth.rule).toBeCloseTo(-0.1)
|
|
33
|
+
})
|
|
34
|
+
it('hash ignores ordering, reason notes and authors but tracks price, quantity, horizon and adjustment changes', () => {
|
|
35
|
+
const other = { ...line, lineId: 'other' }
|
|
36
|
+
const initial = computeWaterfallSummary([line, other], [row], 'USD').snapshotHash
|
|
37
|
+
expect(computeWaterfallSummary([other, line], [{ ...row, reasonNote: 'Edited', reasonCode: 'other', createdByUserId: 'user' }], 'USD').snapshotHash).toBe(initial)
|
|
38
|
+
for (const changed of [{ ...line, quantity: 3 }, { ...line, termMonths: 6 }, { ...line, charges: [{ ...line.charges[0], totalPrice: 70 }] }]) {
|
|
39
|
+
expect(computeWaterfallSummary([changed, other], [row], 'USD').snapshotHash).not.toBe(initial)
|
|
40
|
+
}
|
|
41
|
+
expect(computeWaterfallSummary([line, other], [{ ...row, value: 70 }], 'USD').snapshotHash).not.toBe(initial)
|
|
42
|
+
})
|
|
43
|
+
it('normalizes legacy waterfall fields to the same hash and never changes legacy discountTotal', () => {
|
|
44
|
+
const legacy = { ...line.charges[0] }
|
|
45
|
+
delete legacy.listUnitPrice; delete legacy.listTotalPrice; delete legacy.standardUnitPrice; delete legacy.standardTotalPrice
|
|
46
|
+
const result = computeWaterfallSummary([{ ...line, charges: [legacy] }], [row], 'USD')
|
|
47
|
+
expect(result.snapshotHash).toBe(computeWaterfallSummary([line], [row], 'USD').snapshotHash)
|
|
48
|
+
expect(result).not.toHaveProperty('discountTotal')
|
|
49
|
+
})
|
|
50
|
+
it('stable hash and money after repeated resolver passes', () => {
|
|
51
|
+
const input = [{ ...line, parentLineId: null, enginePriced: true, mirrored: false }]
|
|
52
|
+
const first = resolveManualAdjustments(input, [row], 'USD')
|
|
53
|
+
const second = resolveManualAdjustments(first.lines, [row], 'USD')
|
|
54
|
+
expect(computeWaterfallSummary(first.lines.map((priced) => ({ ...priced, quantity: 2 })), [row], 'USD')).toEqual(computeWaterfallSummary(second.lines.map((priced) => ({ ...priced, quantity: 2 })), [row], 'USD'))
|
|
55
|
+
})
|
|
56
|
+
it('hash tracks deal eligibility and normalizes omitted eligibility to true', () => {
|
|
57
|
+
const baseline = computeWaterfallSummary([line], [], 'USD')
|
|
58
|
+
expect(computeWaterfallSummary([{ ...line, includeInDeal: true }], [], 'USD').snapshotHash).toBe(baseline.snapshotHash)
|
|
59
|
+
const excluded = computeWaterfallSummary([{ ...line, includeInDeal: false }], [], 'USD')
|
|
60
|
+
expect(excluded.dealValue.offer).toBe(0)
|
|
61
|
+
expect(excluded.snapshotHash).not.toBe(baseline.snapshotHash)
|
|
62
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cloneQuoteAdjustments, removeQuoteLineTree } from '../priceAdjustments/quoteAdjustmentLifecycle'
|
|
2
|
+
import { CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
3
|
+
import { SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
4
|
+
const scope = { tenantId: 'tenant', organizationId: 'org' }
|
|
5
|
+
it('clones rows with new ids and remapped lines without changing reasons or the source', async () => {
|
|
6
|
+
const source = { id: 'adjustment', quoteLineId: 'old-line', level: 'charge', chargeCode: 'fee', adjustmentType: 'price_override', value: '80', currencyCode: 'USD', reasonCode: 'inactive_reason', createdByUserId: 'author', windowKind: 'relative', fromMonth: 1, toMonth: null, isActive: true }
|
|
7
|
+
const created: Array<Record<string, unknown>> = []
|
|
8
|
+
const em = { find: jest.fn().mockResolvedValue([source]), create: jest.fn((_entity: unknown, values: Record<string, unknown>) => { created.push(values); return values }), persist: jest.fn() }
|
|
9
|
+
await cloneQuoteAdjustments(em as never, 'source', { id: 'target', quoteId: 'new-sales' }, new Map([['old-line', 'new-line']]), scope)
|
|
10
|
+
expect(created[0]).toMatchObject({ quoteLineId: 'new-line', quoteConfigurationId: 'target', quoteId: 'new-sales', reasonCode: 'inactive_reason', createdByUserId: 'author', ...scope })
|
|
11
|
+
expect(created[0].id).not.toBe(source.id)
|
|
12
|
+
expect(source.quoteLineId).toBe('old-line')
|
|
13
|
+
expect(em.find).toHaveBeenCalledWith(CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: 'source', ...scope, deletedAt: null }), expect.objectContaining({ limit: 500 }))
|
|
14
|
+
})
|
|
15
|
+
it('keeps missing targets invalid with a fresh orphan id rather than a source-quote pointer', async () => {
|
|
16
|
+
const source = { id: 'adjustment', quoteLineId: 'missing', reasonCode: 'promotion' }
|
|
17
|
+
const create = jest.fn((_entity: unknown, values: Record<string, unknown>) => values)
|
|
18
|
+
await cloneQuoteAdjustments({ find: jest.fn().mockResolvedValue([source]), create, persist: jest.fn() } as never, 'source', { id: 'target', quoteId: 'sales' }, new Map(), scope)
|
|
19
|
+
expect(create.mock.calls[0][1].quoteLineId).not.toBe('missing')
|
|
20
|
+
})
|
|
21
|
+
it('soft-deletes the whole nested bundle subtree and its adjustments before returning', async () => {
|
|
22
|
+
const root = { id: 'root-config', quoteLineId: 'root', parentLineId: null, deletedAt: null }
|
|
23
|
+
const child = { id: 'child-config', quoteLineId: 'child', parentLineId: 'root', deletedAt: null }
|
|
24
|
+
const grandchild = { id: 'grandchild-config', quoteLineId: 'grandchild', parentLineId: 'child', deletedAt: null }
|
|
25
|
+
const unrelated = { id: 'other-config', quoteLineId: 'other', parentLineId: null, deletedAt: null }
|
|
26
|
+
const sales = [{ id: 'root', deletedAt: null }, { id: 'child', deletedAt: null }, { id: 'grandchild', deletedAt: null }]
|
|
27
|
+
const adjustments = [{ id: 'adjustment', quoteLineId: 'grandchild', deletedAt: null, isActive: true }]
|
|
28
|
+
const em = { find: jest.fn(async (entity: unknown) => entity === CpqQuoteLineConfiguration ? [root, child, grandchild, unrelated] : entity === SalesQuoteLine ? sales : adjustments), flush: jest.fn().mockResolvedValue(undefined) }
|
|
29
|
+
await removeQuoteLineTree(em as never, 'quote', root as never, scope)
|
|
30
|
+
for (const row of [root, child, grandchild, ...sales, ...adjustments]) expect(row.deletedAt).toBeInstanceOf(Date)
|
|
31
|
+
expect(unrelated.deletedAt).toBe(null)
|
|
32
|
+
expect(adjustments[0].isActive).toBe(false)
|
|
33
|
+
expect(em.flush).toHaveBeenCalled()
|
|
34
|
+
expect(em.find).toHaveBeenCalledWith(CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: 'quote', quoteLineId: { $in: ['root', 'child', 'grandchild'] }, ...scope }), expect.anything())
|
|
35
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DefaultCpqQuotingService } from '../cpqQuotingService'
|
|
2
|
+
import { CpqProductOffering, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
3
|
+
import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
4
|
+
const scope = { tenantId: '11111111-1111-4111-8111-111111111111', organizationId: '22222222-2222-4222-8222-222222222222' }
|
|
5
|
+
const ids = { quote: '33333333-3333-4333-8333-333333333333', sales: '44444444-4444-4444-8444-444444444444', line: '55555555-5555-4555-8555-555555555555', adjustment: '66666666-6666-4666-8666-666666666666', user: '77777777-7777-4777-8777-777777777777' }
|
|
6
|
+
function setup() {
|
|
7
|
+
const quote = { id: ids.quote, quoteId: ids.sales, customerId: 'customer', quoteType: 'new', cpqStatus: 'new', currencyCode: 'USD', pricingRevision: 0, ...scope }
|
|
8
|
+
const sales = { id: ids.sales, quoteNumber: 'Q-1', currencyCode: 'USD' }
|
|
9
|
+
const line = { id: 'config', quoteLineId: ids.line, quoteConfigurationId: ids.quote, offeringId: 'offer', productId: null, parentLineId: null, quantity: 2, configuration: {}, nrcTotal: '0', mrcTotal: '200', isConfigured: true, charges: [], usageEstimates: null, ...scope }
|
|
10
|
+
const salesLine = { id: ids.line, totalNetAmount: '200' }
|
|
11
|
+
const row = { id: ids.adjustment, quoteConfigurationId: ids.quote, quoteId: ids.sales, quoteLineId: ids.line, level: 'charge', chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'discount_absolute', value: '20', currencyCode: 'USD', reasonCode: 'competitive_match', reasonNote: null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: ids.user, ...scope }
|
|
12
|
+
const findOne = jest.fn(async (entity: unknown) => entity === CpqQuoteConfiguration ? quote : entity === SalesQuote ? sales : entity === CpqProductOffering ? { id: 'offer', name: 'Offering', offeringType: 'simple' } : entity === SalesQuoteLine ? salesLine : null)
|
|
13
|
+
const find = jest.fn(async (entity: unknown) => entity === CpqQuoteLineConfiguration ? [line] : entity === CpqQuotePriceAdjustment ? [row] : [])
|
|
14
|
+
const tx = { findOne, find, assign: jest.fn((entity: object, updates: object) => Object.assign(entity, updates)), flush: jest.fn().mockResolvedValue(undefined) }
|
|
15
|
+
const em = { ...tx, fork: jest.fn(), transactional: jest.fn(async (execute: (manager: typeof tx) => Promise<unknown>) => execute(tx)) }
|
|
16
|
+
em.fork.mockReturnValue(em)
|
|
17
|
+
const pricing = { resolveProductCharges: jest.fn().mockResolvedValue([{ chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', unitPrice: 100, totalPrice: 100, quantity: 1, currencyCode: 'USD', adjustments: [] }]) }
|
|
18
|
+
const service = new DefaultCpqQuotingService({ em: em as never, cpqPricingService: pricing as never, cpqValidationService: { validateOfferingConfiguration: async () => ({ errors: [] }), validateRelationships: async () => [] } as never, cpqProductService: {} as never, salesDocumentNumberGenerator: {} as never })
|
|
19
|
+
return { service, quote, line, row, salesLine, em, tx }
|
|
20
|
+
}
|
|
21
|
+
it('applies stored adjustments to quote/sales totals exactly once and preserves deprecated discountTotal', async () => {
|
|
22
|
+
const { service, quote, line, salesLine, em } = setup()
|
|
23
|
+
const first = await service.recalculate(ids.quote, { save: true }, scope)
|
|
24
|
+
expect(first.lines[0].mrcTotal).toBe(160)
|
|
25
|
+
expect(first.lines[0].charges[0]).toMatchObject({ listTotalPrice: 100, standardTotalPrice: 100, totalPrice: 80 })
|
|
26
|
+
expect(first.pricingSummary).toMatchObject({ mrcTotal: 160, manualDiscountTotal: 40, ruleDiscountTotal: 0, discountTotal: 0 })
|
|
27
|
+
expect(first.pricingRevision).toBe(1)
|
|
28
|
+
expect(salesLine.totalNetAmount).toBe('160')
|
|
29
|
+
expect(line.mrcTotal).toBe('160')
|
|
30
|
+
const second = await service.recalculate(ids.quote, { save: true }, scope)
|
|
31
|
+
expect(second.lines[0].charges).toEqual(first.lines[0].charges)
|
|
32
|
+
expect(second.pricingSummary.snapshotHash).toBe(first.pricingSummary.snapshotHash)
|
|
33
|
+
expect(quote.pricingRevision).toBe(2)
|
|
34
|
+
expect(em.transactional).toHaveBeenCalledTimes(2)
|
|
35
|
+
})
|
|
36
|
+
it('keeps an invalid row, marks the line and quote incomplete, and charges standard', async () => {
|
|
37
|
+
const { service, row } = setup()
|
|
38
|
+
row.chargeCode = 'removed-charge'
|
|
39
|
+
const result = await service.recalculate(ids.quote, { save: true }, scope)
|
|
40
|
+
expect(result.cpqStatus).toBe('incomplete')
|
|
41
|
+
expect(result.lines[0].isConfigured).toBe(false)
|
|
42
|
+
expect(result.lines[0].mrcTotal).toBe(200)
|
|
43
|
+
expect(result.lines[0].validationErrors).toEqual([expect.objectContaining({ message: 'ADJUSTMENT_TARGET_MISSING' })])
|
|
44
|
+
expect(row.chargeCode).toBe('removed-charge')
|
|
45
|
+
})
|
|
46
|
+
it('previews saved adjustments without changing a managed entity, revision or persisted snapshot', async () => {
|
|
47
|
+
const { service, quote, line, tx, em } = setup()
|
|
48
|
+
const result = await service.recalculate(ids.quote, { save: false }, scope)
|
|
49
|
+
expect(result.lines[0].mrcTotal).toBe(160)
|
|
50
|
+
expect(quote.cpqStatus).toBe('new')
|
|
51
|
+
expect(quote.pricingRevision).toBe(0)
|
|
52
|
+
expect(quote).not.toHaveProperty('pricingSummary')
|
|
53
|
+
expect(line.mrcTotal).toBe('200')
|
|
54
|
+
expect(line.charges).toEqual([])
|
|
55
|
+
expect(tx.flush).not.toHaveBeenCalled()
|
|
56
|
+
expect(tx.assign).not.toHaveBeenCalled()
|
|
57
|
+
expect(em.transactional).not.toHaveBeenCalled()
|
|
58
|
+
})
|
|
59
|
+
it('loads adjustments only once per recalculation and scopes the query', async () => {
|
|
60
|
+
const { service, tx } = setup()
|
|
61
|
+
await service.recalculate(ids.quote, { save: false }, scope)
|
|
62
|
+
const calls = tx.find.mock.calls.filter(([entity]) => entity === CpqQuotePriceAdjustment)
|
|
63
|
+
expect(calls).toHaveLength(1)
|
|
64
|
+
expect(calls[0]).toEqual([CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: ids.quote, ...scope, deletedAt: null }), { limit: 500, orderBy: { id: 'asc' } }])
|
|
65
|
+
})
|