@dainamite/cpq 0.5.0 → 0.7.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 +64 -0
- package/MIGRATION.md +74 -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/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]/route.js +17 -1
- package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/route.js +47 -2
- package/dist/modules/cpq/api/orders/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 +42 -2
- 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 +32 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/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/backend/cpq/inventory/AssetsTable.js +3 -9
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +21 -25
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +48 -47
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +3 -3
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/shared.js +37 -2
- package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +174 -184
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +168 -143
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/page.js +36 -34
- package/dist/modules/cpq/backend/cpq/offerings/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +140 -280
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/page.js +6 -6
- package/dist/modules/cpq/backend/cpq/orders/page.js.map +2 -2
- 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/PriceRuleForm.js +41 -36
- package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +76 -72
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/price-rules/page.js +18 -15
- package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +23 -21
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +22 -50
- package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +74 -52
- package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +20 -17
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/page.js +1 -1
- package/dist/modules/cpq/backend/cpq/pricing/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +115 -123
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +208 -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 +269 -311
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
- package/dist/modules/cpq/backend/cpq/quotes/page.js +23 -19
- package/dist/modules/cpq/backend/cpq/quotes/page.js.map +2 -2
- 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/[id]/DependsOnRulesEditor.js +19 -15
- package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +171 -178
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/page.js +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +60 -50
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +22 -18
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/wizards/page.js +1 -1
- package/dist/modules/cpq/backend/cpq/wizards/page.js.map +2 -2
- 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 +148 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
- package/dist/modules/cpq/components/PriceWaterfall.js +68 -0
- package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js +112 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
- package/dist/modules/cpq/components/StatusPath.js +5 -3
- package/dist/modules/cpq/components/StatusPath.js.map +2 -2
- package/dist/modules/cpq/components/useCpqRowActions.js +10 -22
- package/dist/modules/cpq/components/useCpqRowActions.js.map +2 -2
- 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/i18n/de.json +1220 -0
- package/dist/modules/cpq/i18n/en.json +1220 -0
- package/dist/modules/cpq/i18n/es.json +1220 -0
- package/dist/modules/cpq/i18n/pl.json +1220 -0
- 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 +25 -28
- 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 +330 -109
- 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/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +39 -31
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +2 -2
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +13 -5
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +2 -2
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +41 -21
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +2 -2
- package/dist/modules/cpq/widgets/injection-table.js +2 -2
- package/dist/modules/cpq/widgets/injection-table.js.map +2 -2
- package/dist/modules/cpq/workflows/WizardRunner.js +10 -8
- package/dist/modules/cpq/workflows/WizardRunner.js.map +2 -2
- package/dist/modules/cpq/workflows/WizardTrigger.js +15 -9
- package/dist/modules/cpq/workflows/WizardTrigger.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +9 -7
- package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +9 -4
- package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/ItemListStep.js +16 -19
- package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +19 -32
- package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +8 -6
- package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/ReviewStep.js +32 -30
- package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +2 -2
- package/dist/modules/cpq/workflows/steps/index.js +12 -0
- package/dist/modules/cpq/workflows/steps/index.js.map +2 -2
- package/package.json +1 -1
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
- package/src/modules/cpq/__tests__/i18n/dictionaries.test.ts +62 -0
- package/src/modules/cpq/__tests__/i18n/hardcodedStrings.test.ts +35 -0
- package/src/modules/cpq/__tests__/i18n/sourceScan.ts +109 -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/quoteMutationGuard.ts +38 -0
- package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
- package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
- package/src/modules/cpq/api/orders/route.ts +51 -1
- 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 +48 -1
- 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 +40 -1
- 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/backend/cpq/inventory/AssetsTable.tsx +3 -9
- package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +22 -26
- package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +43 -46
- package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +3 -3
- package/src/modules/cpq/backend/cpq/inventory/shared.tsx +37 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +164 -120
- package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +169 -130
- package/src/modules/cpq/backend/cpq/offerings/page.tsx +34 -24
- package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +122 -308
- package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/orders/page.tsx +6 -6
- package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +48 -34
- package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +76 -70
- package/src/modules/cpq/backend/cpq/price-rules/page.tsx +25 -16
- package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -18
- package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +29 -19
- package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +95 -47
- package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +23 -15
- package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +111 -88
- 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 +304 -348
- package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/quotes/page.tsx +29 -21
- package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +19 -15
- package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +171 -171
- package/src/modules/cpq/backend/cpq/specifications/page.tsx +2 -2
- package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +51 -41
- package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +17 -13
- 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 +203 -0
- package/src/modules/cpq/components/PriceWaterfall.tsx +96 -0
- package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
- package/src/modules/cpq/components/StatusPath.tsx +7 -4
- package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
- package/src/modules/cpq/components/useCpqRowActions.tsx +17 -25
- 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/i18n/de.json +1220 -0
- package/src/modules/cpq/i18n/en.json +1220 -0
- package/src/modules/cpq/i18n/es.json +1220 -0
- package/src/modules/cpq/i18n/pl.json +1220 -0
- 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__/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 +22 -28
- package/src/modules/cpq/services/cpqPricingService.ts +8 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +374 -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
- package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +38 -31
- package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +7 -5
- package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +36 -21
- package/src/modules/cpq/widgets/injection-table.ts +8 -2
- package/src/modules/cpq/workflows/WizardRunner.tsx +8 -5
- package/src/modules/cpq/workflows/WizardTrigger.tsx +15 -9
- package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +11 -7
- package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +9 -5
- package/src/modules/cpq/workflows/steps/ItemListStep.tsx +18 -12
- package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +20 -16
- package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +8 -6
- package/src/modules/cpq/workflows/steps/ReviewStep.tsx +33 -18
- package/src/modules/cpq/workflows/steps/index.ts +12 -0
- package/src/modules/cpq/workflows/types.ts +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
export class Migration20261110213052 extends Migration {
|
|
4
|
+
|
|
5
|
+
override name = 'Migration20261110213052';
|
|
6
|
+
|
|
7
|
+
override up(): void | Promise<void> {
|
|
8
|
+
this.addSql(`create table "cpq_quote_price_adjustments" ("id" uuid not null default gen_random_uuid(), "organization_id" uuid not null, "tenant_id" uuid not null, "quote_configuration_id" uuid not null, "quote_id" uuid not null, "quote_line_id" uuid not null, "level" text not null, "charge_code" text null, "charge_type_filter" text null, "adjustment_type" text not null, "value" numeric(18, 4) not null, "currency_code" text null, "window_kind" text not null default 'relative', "from_month" int not null default 1, "to_month" int null, "reason_code" text not null, "reason_note" text null, "created_by_user_id" uuid not null, "updated_by_user_id" uuid null, "is_active" boolean not null default true, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, primary key ("id"));`);
|
|
9
|
+
this.addSql(`create index "cpq_qpa_quote_idx" on "cpq_quote_price_adjustments" ("organization_id", "tenant_id", "quote_id");`);
|
|
10
|
+
this.addSql(`create index "cpq_qpa_quote_line_idx" on "cpq_quote_price_adjustments" ("organization_id", "tenant_id", "quote_line_id");`);
|
|
11
|
+
this.addSql(`create index "cpq_qpa_quote_config_idx" on "cpq_quote_price_adjustments" ("organization_id", "tenant_id", "quote_configuration_id");`);
|
|
12
|
+
|
|
13
|
+
this.addSql(`alter table "cpq_quote_configurations" add "pricing_revision" int not null default 0;`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override down(): void | Promise<void> {
|
|
17
|
+
this.addSql(`drop table if exists "cpq_quote_price_adjustments" cascade;`);
|
|
18
|
+
|
|
19
|
+
this.addSql(`alter table "cpq_quote_configurations" drop column "pricing_revision";`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
}
|
|
@@ -15,6 +15,8 @@ import type { TenantScope } from '../../types'
|
|
|
15
15
|
* pre-existing tests that only inspect `em.create.mock.calls` are unaffected.
|
|
16
16
|
*/
|
|
17
17
|
export interface MockEm {
|
|
18
|
+
fork: jest.MockedFunction<() => MockEm>
|
|
19
|
+
transactional: jest.MockedFunction<(callback: (em: MockEm) => Promise<unknown>) => Promise<unknown>>
|
|
18
20
|
findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
|
|
19
21
|
find: jest.MockedFunction<
|
|
20
22
|
(
|
|
@@ -39,9 +41,11 @@ export interface MockEm {
|
|
|
39
41
|
let __syntheticIdSequence = 0
|
|
40
42
|
|
|
41
43
|
export function createMockEm(): MockEm {
|
|
42
|
-
|
|
44
|
+
const em: MockEm = {
|
|
45
|
+
fork: jest.fn(),
|
|
46
|
+
transactional: jest.fn(),
|
|
43
47
|
findOne: jest.fn(),
|
|
44
|
-
find: jest.fn(),
|
|
48
|
+
find: jest.fn().mockResolvedValue([]),
|
|
45
49
|
findAndCount: jest.fn(),
|
|
46
50
|
count: jest.fn().mockResolvedValue(0),
|
|
47
51
|
create: jest.fn().mockImplementation((_e, data: Record<string, unknown>) => ({
|
|
@@ -51,6 +55,9 @@ export function createMockEm(): MockEm {
|
|
|
51
55
|
persist: jest.fn(),
|
|
52
56
|
flush: jest.fn().mockResolvedValue(undefined),
|
|
53
57
|
}
|
|
58
|
+
em.fork.mockReturnValue(em)
|
|
59
|
+
em.transactional.mockImplementation((callback) => callback(em))
|
|
60
|
+
return em
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
export const TEST_SCOPE: TenantScope = {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { CpqAdjustmentService } from '../priceAdjustments/adjustmentService'
|
|
2
|
+
import { CpqOrderConfiguration, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
3
|
+
import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
|
|
4
|
+
import { adjustmentInput } from '../priceAdjustments/adjustmentService'
|
|
5
|
+
import { resolveManualAdjustments } from '../priceAdjustments/resolveManualAdjustments'
|
|
6
|
+
import type { PriceAdjustmentInput, PriceAdjustment } from '../../data/price-adjustments'
|
|
7
|
+
const scope = { tenantId: '11111111-1111-4111-8111-111111111111', organizationId: '22222222-2222-4222-8222-222222222222' }
|
|
8
|
+
const quoteId = '33333333-3333-4333-8333-333333333333', lineId = '44444444-4444-4444-8444-444444444444', actor = '55555555-5555-4555-8555-555555555555'
|
|
9
|
+
const input: PriceAdjustmentInput = { level: 'charge', quoteLineId: lineId, chargeCode: 'fee', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
|
|
10
|
+
function setup() {
|
|
11
|
+
const quote = { id: quoteId, quoteId, ...scope, cpqStatus: 'ready', pricingRevision: 0, quoteType: 'new' }
|
|
12
|
+
const rows: CpqQuotePriceAdjustment[] = []
|
|
13
|
+
const state = { enabled: true, order: false, reason: true, target: true, mirrored: false }
|
|
14
|
+
const em = {
|
|
15
|
+
fork: jest.fn(), transactional: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
|
|
16
|
+
assign: jest.fn((entity: object, values: object) => Object.assign(entity, values)),
|
|
17
|
+
create: jest.fn((_entity: unknown, values: object) => ({ fromMonth: 1, toMonth: null, windowKind: 'relative', isActive: true, deletedAt: null, createdAt: new Date(), updatedAt: new Date(), ...values })),
|
|
18
|
+
persist: jest.fn((row: CpqQuotePriceAdjustment) => { if (!rows.includes(row)) rows.push(row) }),
|
|
19
|
+
find: jest.fn(async (entity: unknown) => entity === DictionaryEntry ? [{ value: 'competitive_match', label: 'Competitive match' }] : rows.filter((row) => !row.deletedAt)),
|
|
20
|
+
count: jest.fn(async () => rows.filter((row) => !row.deletedAt).length),
|
|
21
|
+
findOne: jest.fn(async (entity: unknown, where: Record<string, unknown>) => {
|
|
22
|
+
if (entity === CpqQuoteConfiguration) return quote
|
|
23
|
+
if (entity === CpqOrderConfiguration) return state.order ? { id: 'order' } : null
|
|
24
|
+
if (entity === CpqQuoteLineConfiguration) return state.target ? { offeringId: 'offer', configuration: state.mirrored ? { _arcMirroredFromItemId: 'item' } : {} } : null
|
|
25
|
+
if (entity === Dictionary) return { id: 'dictionary' }
|
|
26
|
+
if (entity === DictionaryEntry) return state.reason ? { value: where.value } : null
|
|
27
|
+
if (entity === CpqQuotePriceAdjustment) return rows.find((row) => row.id === where.id && (where.deletedAt === undefined || row.deletedAt === null)) ?? null
|
|
28
|
+
return null
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
em.fork.mockReturnValue(em)
|
|
32
|
+
em.transactional.mockImplementation(async (execute: (tx: typeof em) => Promise<unknown>) => execute(em))
|
|
33
|
+
const recalculateLockedQuote = jest.fn(async (_em?: unknown, _quote?: unknown, _scope?: unknown, _save?: boolean, draft?: PriceAdjustment[]) => {
|
|
34
|
+
const resolved = resolveManualAdjustments([{ lineId, parentLineId: null, enginePriced: true, mirrored: false,
|
|
35
|
+
charges: [{ chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', currencyCode: 'USD', unitPrice: 100, totalPrice: 100, quantity: 1 }] }],
|
|
36
|
+
draft ?? rows.filter((row) => !row.deletedAt).map((row) => ({ ...adjustmentInput(row), id: row.id })), 'USD')
|
|
37
|
+
return { id: quoteId, adjustmentErrors: resolved.errors, lines: resolved.lines }
|
|
38
|
+
})
|
|
39
|
+
const service = new CpqAdjustmentService({ em: em as never, quotingService: { recalculateLockedQuote } as never,
|
|
40
|
+
featureTogglesService: { getBoolConfig: async () => ({ ok: true, value: state.enabled }) } as never })
|
|
41
|
+
return { service, em, quote, rows, state, recalculateLockedQuote }
|
|
42
|
+
}
|
|
43
|
+
it('creates a negotiated price and undoes it under the same pricing lock', async () => {
|
|
44
|
+
const { service, rows, quote, em } = setup()
|
|
45
|
+
const created = await service.create(quoteId, { ...input, expectedRevision: 0 }, actor, scope)
|
|
46
|
+
expect(created.before).toBe(null)
|
|
47
|
+
expect(created.adjustment).toMatchObject({ value: 80, quoteConfigurationId: quoteId, createdByUserId: actor, ...scope })
|
|
48
|
+
expect(quote.pricingRevision).toBe(1)
|
|
49
|
+
await service.undo(quoteId, created.adjustment.id, null, created.adjustment, actor, scope)
|
|
50
|
+
expect(rows[0].deletedAt).toBeInstanceOf(Date)
|
|
51
|
+
expect(quote.pricingRevision).toBe(2)
|
|
52
|
+
expect(em.fork).toHaveBeenCalledWith({ clear: true, useContext: false })
|
|
53
|
+
})
|
|
54
|
+
it('updates and restores previous values, accepting reordered JSON undo keys', async () => {
|
|
55
|
+
const { service, rows } = setup()
|
|
56
|
+
const created = await service.create(quoteId, input, actor, scope)
|
|
57
|
+
const updated = await service.update(quoteId, created.adjustment.id, { value: 70 }, actor, scope)
|
|
58
|
+
const reordered = Object.fromEntries(Object.entries(updated.adjustment).reverse()) as typeof updated.adjustment
|
|
59
|
+
await service.undo(quoteId, updated.adjustment.id, updated.before, reordered, actor, scope)
|
|
60
|
+
expect(rows[0].value).toBe('80')
|
|
61
|
+
})
|
|
62
|
+
it('deletes and restores a row, preserving its original reason even if no longer selectable', async () => {
|
|
63
|
+
const { service, rows, state } = setup()
|
|
64
|
+
const created = await service.create(quoteId, input, actor, scope)
|
|
65
|
+
state.reason = false
|
|
66
|
+
const removed = await service.remove(quoteId, created.adjustment.id, actor, scope)
|
|
67
|
+
expect(rows[0].isActive).toBe(false)
|
|
68
|
+
await service.undo(quoteId, removed.adjustment.id, removed.before, removed.adjustment, actor, scope)
|
|
69
|
+
expect(rows[0].isActive).toBe(true)
|
|
70
|
+
expect(rows[0].deletedAt).toBe(null)
|
|
71
|
+
})
|
|
72
|
+
it.each(['in_approval', 'accepted', 'cancelled', 'with_customer'])('refuses create and undo in locked status %s', async (status) => {
|
|
73
|
+
const { service, quote } = setup()
|
|
74
|
+
const created = await service.create(quoteId, input, actor, scope)
|
|
75
|
+
quote.cpqStatus = status
|
|
76
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 409, message: 'ADJUSTMENT_LOCKED' })
|
|
77
|
+
await expect(service.undo(quoteId, created.adjustment.id, null, created.adjustment, actor, scope)).rejects.toMatchObject({ status: 409 })
|
|
78
|
+
})
|
|
79
|
+
it('refuses writes when converted or disabled', async () => {
|
|
80
|
+
const { service, state, em } = setup()
|
|
81
|
+
state.enabled = false
|
|
82
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 403 })
|
|
83
|
+
expect(em.transactional).not.toHaveBeenCalled()
|
|
84
|
+
state.enabled = true; state.order = true
|
|
85
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 409 })
|
|
86
|
+
})
|
|
87
|
+
it('rejects invalid prices, missing reasons, overlaps and stale revisions', async () => {
|
|
88
|
+
const { service, state } = setup()
|
|
89
|
+
await expect(service.create(quoteId, { ...input, value: 120 }, actor, scope)).rejects.toMatchObject({ status: 422, message: 'ABOVE_STANDARD' })
|
|
90
|
+
state.reason = false
|
|
91
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'REASON_NOT_FOUND' })
|
|
92
|
+
const clean = setup()
|
|
93
|
+
await clean.service.create(quoteId, input, actor, scope)
|
|
94
|
+
await expect(clean.service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_OVERLAP' })
|
|
95
|
+
await expect(clean.service.create(quoteId, { ...input, expectedRevision: 0 }, actor, scope)).rejects.toMatchObject({ status: 409, message: 'STALE_REVISION' })
|
|
96
|
+
})
|
|
97
|
+
it('allows deleting an invalid adjustment while submitted', async () => {
|
|
98
|
+
const { service, rows, quote } = setup()
|
|
99
|
+
const created = await service.create(quoteId, input, actor, scope)
|
|
100
|
+
rows[0].value = '120'; quote.cpqStatus = 'in_approval'
|
|
101
|
+
await service.remove(quoteId, created.adjustment.id, actor, scope)
|
|
102
|
+
expect(rows[0].deletedAt).toBeInstanceOf(Date)
|
|
103
|
+
})
|
|
104
|
+
it('refuses undo if the row changed afterward or the stored tenant is wrong', async () => {
|
|
105
|
+
const { service, rows } = setup()
|
|
106
|
+
const created = await service.create(quoteId, input, actor, scope)
|
|
107
|
+
rows[0].value = '70'
|
|
108
|
+
await expect(service.undo(quoteId, created.adjustment.id, null, created.adjustment, actor, scope)).rejects.toMatchObject({ status: 409 })
|
|
109
|
+
await expect(service.undo(quoteId, created.adjustment.id, null, { ...created.adjustment, tenantId: actor }, actor, scope)).rejects.toMatchObject({ status: 403 })
|
|
110
|
+
})
|
|
111
|
+
it('rejects missing/out-of-scope and ARC mirror targets before persisting', async () => {
|
|
112
|
+
const { service, state, quote, em } = setup()
|
|
113
|
+
quote.quoteType = 'amend'
|
|
114
|
+
state.target = false
|
|
115
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 404 })
|
|
116
|
+
state.target = true; state.mirrored = true
|
|
117
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'LINE_NOT_ELIGIBLE' })
|
|
118
|
+
expect(em.persist).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
it('scopes and paginates reads while reporting the revision and toggle state', async () => {
|
|
121
|
+
const { service, em } = setup()
|
|
122
|
+
await service.create(quoteId, input, actor, scope)
|
|
123
|
+
const listed = await service.list(quoteId, scope, 1, 25)
|
|
124
|
+
expect(listed).toMatchObject({ total: 1, page: 1, pageSize: 25, pricingRevision: 1, enabled: true })
|
|
125
|
+
expect(listed.reasons).toEqual([{ value: 'competitive_match', label: 'Competitive match' }])
|
|
126
|
+
expect(em.find).toHaveBeenCalledWith(CpqQuotePriceAdjustment, expect.objectContaining({ quoteConfigurationId: quoteId, ...scope, deletedAt: null, isActive: true }), { limit: 25, offset: 0, orderBy: { createdAt: 'asc', id: 'asc' } })
|
|
127
|
+
})
|
|
128
|
+
it('previews a draft without persisting rows, revision, or any flush', async () => {
|
|
129
|
+
const { service, em, quote, rows, recalculateLockedQuote } = setup()
|
|
130
|
+
const preview = await service.preview(quoteId, input, undefined, scope)
|
|
131
|
+
expect(preview.errors).toEqual([])
|
|
132
|
+
expect(preview.quote.lines[0].charges[0].totalPrice).toBe(80)
|
|
133
|
+
expect(rows).toEqual([])
|
|
134
|
+
expect(quote.pricingRevision).toBe(0)
|
|
135
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
136
|
+
expect(em.persist).not.toHaveBeenCalled()
|
|
137
|
+
expect(em.transactional).not.toHaveBeenCalled()
|
|
138
|
+
expect(recalculateLockedQuote).toHaveBeenCalledWith(em, quote, scope, false, expect.any(Array))
|
|
139
|
+
})
|
|
140
|
+
it('returns invalid draft errors without saving and refuses a nonexistent replacement', async () => {
|
|
141
|
+
const { service, em } = setup()
|
|
142
|
+
const preview = await service.preview(quoteId, { ...input, value: 120 }, undefined, scope)
|
|
143
|
+
expect(preview.errors).toEqual([expect.objectContaining({ code: 'ABOVE_STANDARD' })])
|
|
144
|
+
await expect(service.preview(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 404 })
|
|
145
|
+
expect(em.flush).not.toHaveBeenCalled()
|
|
146
|
+
})
|
|
147
|
+
it('allows a new quote cloned from an ARC mirror to negotiate its engine price', async () => {
|
|
148
|
+
const { service, state } = setup()
|
|
149
|
+
state.mirrored = true
|
|
150
|
+
const result = await service.create(quoteId, input, actor, scope)
|
|
151
|
+
expect(result.adjustment.value).toBe(80)
|
|
152
|
+
})
|
|
153
|
+
it('refuses an overlapping adjustment at write time, even when the stored one is currently invalid', async () => {
|
|
154
|
+
const { service, rows, quote } = setup()
|
|
155
|
+
// 120 > standard 100 → ABOVE_STANDARD would reject it; store it directly as a pre-existing invalid row.
|
|
156
|
+
rows.push({ id: '66666666-6666-4666-8666-666666666666', quoteConfigurationId: quoteId, quoteId, ...scope, level: 'charge', quoteLineId: lineId,
|
|
157
|
+
chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'price_override', value: '120', currencyCode: 'USD', reasonCode: 'competitive_match',
|
|
158
|
+
reasonNote: null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: actor, isActive: true, deletedAt: null,
|
|
159
|
+
createdAt: new Date(), updatedAt: new Date() } as unknown as CpqQuotePriceAdjustment)
|
|
160
|
+
await expect(service.create(quoteId, input, actor, scope)).rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_OVERLAP' })
|
|
161
|
+
expect(rows).toHaveLength(1)
|
|
162
|
+
expect(quote.pricingRevision).toBe(1) // bumped in the rolled-back transaction only
|
|
163
|
+
})
|
|
164
|
+
it('allows a recurring and a one-time line discount side by side, but not two covering all charges', async () => {
|
|
165
|
+
const { adjustmentsOverlap } = await import('../priceAdjustments/adjustmentService')
|
|
166
|
+
const base = { level: 'line', quoteLineId: 'l1', chargeCode: null }
|
|
167
|
+
expect(adjustmentsOverlap({ ...base, id: 'a', chargeTypeFilter: 'mrc' }, { ...base, id: 'b', chargeTypeFilter: 'nrc' })).toBe(false)
|
|
168
|
+
expect(adjustmentsOverlap({ ...base, id: 'a', chargeTypeFilter: 'mrc' }, { ...base, id: 'b', chargeTypeFilter: null })).toBe(true)
|
|
169
|
+
expect(adjustmentsOverlap({ ...base, id: 'a', chargeTypeFilter: null }, { ...base, id: 'a', chargeTypeFilter: null })).toBe(false)
|
|
170
|
+
expect(adjustmentsOverlap({ id: 'a', level: 'charge', quoteLineId: 'l1', chargeCode: 'fee' }, { id: 'b', level: 'charge', quoteLineId: 'l1', chargeCode: 'setup' })).toBe(false)
|
|
171
|
+
expect(adjustmentsOverlap({ id: 'a', level: 'charge', quoteLineId: 'l1', chargeCode: 'fee' }, { id: 'b', level: 'line', quoteLineId: 'l1' })).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
it('refuses to undo a delete when a rep has since added an overlapping adjustment', async () => {
|
|
174
|
+
const { service, rows } = setup()
|
|
175
|
+
const first = await service.create(quoteId, input, actor, scope)
|
|
176
|
+
await service.remove(quoteId, first.adjustment.id, actor, scope)
|
|
177
|
+
expect(rows[0].isActive).toBe(false)
|
|
178
|
+
await service.create(quoteId, { ...input, value: 70 }, actor, scope)
|
|
179
|
+
const deletedSnapshot = { ...first.adjustment, deletedAt: rows[0].deletedAt!.toISOString(), isActive: false, updatedAt: rows[0].updatedAt.toISOString() }
|
|
180
|
+
await expect(service.undo(quoteId, first.adjustment.id, first.adjustment, deletedSnapshot, actor, scope))
|
|
181
|
+
.rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_OVERLAP' })
|
|
182
|
+
expect(rows.filter((row) => row.isActive)).toHaveLength(1)
|
|
183
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectLineSubtree,
|
|
3
|
+
isArcCancelledLine,
|
|
4
|
+
planArcLineRemoval,
|
|
5
|
+
planArcLineRestore,
|
|
6
|
+
removalCancelsItem,
|
|
7
|
+
type ArcRemovalLine,
|
|
8
|
+
} from '../arcLineRemoval'
|
|
9
|
+
|
|
10
|
+
const mirror = (quoteLineId: string, parentLineId: string | null = null, action = 'modify'): ArcRemovalLine => ({
|
|
11
|
+
quoteLineId,
|
|
12
|
+
parentLineId,
|
|
13
|
+
action,
|
|
14
|
+
configuration: { _arcMirroredFromItemId: `item-${quoteLineId}`, _arcMirroredName: quoteLineId },
|
|
15
|
+
})
|
|
16
|
+
const added = (quoteLineId: string, parentLineId: string | null = null): ArcRemovalLine => ({
|
|
17
|
+
quoteLineId,
|
|
18
|
+
parentLineId,
|
|
19
|
+
action: 'add',
|
|
20
|
+
configuration: { port_speed: '10G' },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('removalCancelsItem', () => {
|
|
24
|
+
it.each(['amend', 'renew'])('is true for a mirror line on a %s quote', (quoteType) => {
|
|
25
|
+
expect(removalCancelsItem(mirror('a'), quoteType)).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('is false for a line the quote added', () => {
|
|
29
|
+
expect(removalCancelsItem(added('a'), 'amend')).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it.each(['new', 'cancel', null])('is false on a %s quote', (quoteType) => {
|
|
33
|
+
expect(removalCancelsItem(mirror('a'), quoteType)).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('isArcCancelledLine', () => {
|
|
38
|
+
it('is true for a cancel line on an Amend / Renew quote', () => {
|
|
39
|
+
expect(isArcCancelledLine({ action: 'cancel' }, 'amend')).toBe(true)
|
|
40
|
+
expect(isArcCancelledLine({ action: 'cancel' }, 'renew')).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('is false on a Cancel quote, where every line is a cancel line and keeps its price', () => {
|
|
44
|
+
expect(isArcCancelledLine({ action: 'cancel' }, 'cancel')).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('is false for modify / add lines', () => {
|
|
48
|
+
expect(isArcCancelledLine({ action: 'modify' }, 'amend')).toBe(false)
|
|
49
|
+
expect(isArcCancelledLine({ action: 'add' }, 'amend')).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('collectLineSubtree', () => {
|
|
54
|
+
it('returns the root and every descendant, root first', () => {
|
|
55
|
+
const lines = [mirror('root'), mirror('child', 'root'), mirror('grandchild', 'child'), mirror('other')]
|
|
56
|
+
expect(collectLineSubtree(lines[0], lines).map((l) => l.quoteLineId)).toEqual(['root', 'child', 'grandchild'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('terminates on a parent cycle', () => {
|
|
60
|
+
const a = mirror('a', 'b')
|
|
61
|
+
const b = mirror('b', 'a')
|
|
62
|
+
expect(collectLineSubtree(a, [a, b]).map((l) => l.quoteLineId)).toEqual(['a', 'b'])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('planArcLineRemoval', () => {
|
|
67
|
+
it('cancels a single mirror line and deletes nothing', () => {
|
|
68
|
+
const line = mirror('port')
|
|
69
|
+
expect(planArcLineRemoval(line, [line, added('new-port')], 'amend')).toEqual({ cancel: [line], removeRoots: [] })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('cancels mirrored components with their bundle and deletes components this quote added', () => {
|
|
73
|
+
const bundle = mirror('bundle')
|
|
74
|
+
const existingComponent = mirror('comp-1', 'bundle')
|
|
75
|
+
const newComponent = added('comp-2', 'bundle')
|
|
76
|
+
const newGrandchild = added('comp-2a', 'comp-2')
|
|
77
|
+
const plan = planArcLineRemoval(bundle, [bundle, existingComponent, newComponent, newGrandchild], 'amend')
|
|
78
|
+
expect(plan.cancel.map((l) => l.quoteLineId)).toEqual(['bundle', 'comp-1'])
|
|
79
|
+
// Only the subtree root is deleted — removing it takes its descendants with it.
|
|
80
|
+
expect(plan.removeRoots.map((l) => l.quoteLineId)).toEqual(['comp-2'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('leaves components that are already cancelled alone', () => {
|
|
84
|
+
const bundle = mirror('bundle')
|
|
85
|
+
const cancelled = mirror('comp-1', 'bundle', 'cancel')
|
|
86
|
+
expect(planArcLineRemoval(bundle, [bundle, cancelled], 'amend').cancel).toEqual([bundle])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('planArcLineRestore', () => {
|
|
91
|
+
it('restores the line and its cancelled mirrored components', () => {
|
|
92
|
+
const bundle = mirror('bundle', null, 'cancel')
|
|
93
|
+
const component = mirror('comp-1', 'bundle', 'cancel')
|
|
94
|
+
const plan = planArcLineRestore(bundle, [bundle, component], 'amend')
|
|
95
|
+
expect(plan).toEqual({ ok: true, restore: [bundle, component] })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is blocked while the parent item is still marked for removal', () => {
|
|
99
|
+
const bundle = mirror('bundle', null, 'cancel')
|
|
100
|
+
const component = mirror('comp-1', 'bundle', 'cancel')
|
|
101
|
+
expect(planArcLineRestore(component, [bundle, component], 'amend')).toEqual({ ok: false, reason: 'parent_cancelled' })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('restores a component on its own when the parent stays', () => {
|
|
105
|
+
const bundle = mirror('bundle')
|
|
106
|
+
const component = mirror('comp-1', 'bundle', 'cancel')
|
|
107
|
+
expect(planArcLineRestore(component, [bundle, component], 'amend')).toEqual({ ok: true, restore: [component] })
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ARC_PRICE_FROZEN_KEY,
|
|
3
|
+
arcPricingInputsChanged,
|
|
4
|
+
buildArcMirrorConfiguration,
|
|
5
|
+
ARC_REPRICED_KEY,
|
|
6
|
+
isArcPriceFrozen,
|
|
7
|
+
isFrozenAmendMirrorLine,
|
|
8
|
+
stripArcMarkers,
|
|
9
|
+
stripQuoteOnlyArcMarkers,
|
|
10
|
+
} from '../arcPriceFreeze'
|
|
11
|
+
|
|
12
|
+
const ITEM = { id: 'item-1', name: 'GIX Access Port' }
|
|
13
|
+
|
|
14
|
+
describe('buildArcMirrorConfiguration', () => {
|
|
15
|
+
it('freezes Amend mirror lines and keeps the item configuration', () => {
|
|
16
|
+
const cfg = buildArcMirrorConfiguration({ port_size: '1G' }, ITEM, 'amend')
|
|
17
|
+
expect(cfg).toEqual({
|
|
18
|
+
port_size: '1G',
|
|
19
|
+
_arcMirroredFromItemId: 'item-1',
|
|
20
|
+
_arcMirroredName: 'GIX Access Port',
|
|
21
|
+
[ARC_PRICE_FROZEN_KEY]: true,
|
|
22
|
+
})
|
|
23
|
+
expect(isArcPriceFrozen(cfg)).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it.each(['renew', 'cancel'] as const)('does not freeze %s mirror lines', (quoteType) => {
|
|
27
|
+
const cfg = buildArcMirrorConfiguration({ port_size: '1G' }, ITEM, quoteType)
|
|
28
|
+
expect(isArcPriceFrozen(cfg)).toBe(false)
|
|
29
|
+
expect(cfg._arcMirroredFromItemId).toBe('item-1')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('never inherits stale ARC markers from the item configuration', () => {
|
|
33
|
+
const stale = { port_size: '1G', _arcPriceFrozen: true, _arcMirroredFromItemId: 'old-item', _arcMirroredName: 'Old' }
|
|
34
|
+
const renew = buildArcMirrorConfiguration(stale, ITEM, 'renew')
|
|
35
|
+
expect(isArcPriceFrozen(renew)).toBe(false)
|
|
36
|
+
expect(renew).toEqual({ port_size: '1G', _arcMirroredFromItemId: 'item-1', _arcMirroredName: 'GIX Access Port' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('handles an item without configuration', () => {
|
|
40
|
+
expect(buildArcMirrorConfiguration(null, ITEM, 'amend')).toMatchObject({ _arcMirroredName: 'GIX Access Port' })
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('isArcPriceFrozen', () => {
|
|
45
|
+
it('is false for missing or non-boolean markers', () => {
|
|
46
|
+
expect(isArcPriceFrozen(null)).toBe(false)
|
|
47
|
+
expect(isArcPriceFrozen({})).toBe(false)
|
|
48
|
+
expect(isArcPriceFrozen({ [ARC_PRICE_FROZEN_KEY]: 'true' })).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('arcPricingInputsChanged', () => {
|
|
53
|
+
const before = {
|
|
54
|
+
configuration: { port_size: '1G', data_centre: 'ld5', _arcMirroredFromItemId: 'item-1', [ARC_PRICE_FROZEN_KEY]: true },
|
|
55
|
+
quantity: 2,
|
|
56
|
+
termMonths: 12,
|
|
57
|
+
startDate: new Date('2027-01-01T00:00:00.000Z'),
|
|
58
|
+
endDate: null,
|
|
59
|
+
usageEstimates: [{ chargeCode: 'overage', estimatedQuantity: 10 }],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('is false for an unchanged resubmission (markers stripped, key order differs, fields omitted)', () => {
|
|
63
|
+
expect(arcPricingInputsChanged(before, { configuration: { data_centre: 'ld5', port_size: '1G' } })).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('is false when unchanged values are resubmitted explicitly', () => {
|
|
67
|
+
expect(
|
|
68
|
+
arcPricingInputsChanged(before, {
|
|
69
|
+
configuration: { port_size: '1G', data_centre: 'ld5' },
|
|
70
|
+
quantity: 2,
|
|
71
|
+
termMonths: 12,
|
|
72
|
+
startDate: '2027-01-01',
|
|
73
|
+
usageEstimates: [{ chargeCode: 'overage', estimatedQuantity: 10 }],
|
|
74
|
+
}),
|
|
75
|
+
).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('detects a configuration change', () => {
|
|
79
|
+
expect(arcPricingInputsChanged(before, { configuration: { port_size: '10G', data_centre: 'ld5' } })).toBe(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('detects an added configuration key', () => {
|
|
83
|
+
expect(
|
|
84
|
+
arcPricingInputsChanged(before, { configuration: { port_size: '1G', data_centre: 'ld5', cloud_provider: 'aws' } }),
|
|
85
|
+
).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it.each([
|
|
89
|
+
['quantity', { quantity: 3 }],
|
|
90
|
+
['termMonths', { termMonths: 24 }],
|
|
91
|
+
['startDate', { startDate: '2027-02-01' }],
|
|
92
|
+
['endDate', { endDate: '2028-01-01' }],
|
|
93
|
+
['usageEstimates', { usageEstimates: [{ chargeCode: 'overage', estimatedQuantity: 20 }] }],
|
|
94
|
+
])('detects a %s change', (_field, change) => {
|
|
95
|
+
expect(arcPricingInputsChanged(before, { configuration: { port_size: '1G', data_centre: 'ld5' }, ...change })).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('isFrozenAmendMirrorLine', () => {
|
|
100
|
+
const mirrored = { port_size: '1G', _arcMirroredFromItemId: 'item-1', [ARC_PRICE_FROZEN_KEY]: true }
|
|
101
|
+
|
|
102
|
+
it('is true only for a flagged mirror line on an Amend quote', () => {
|
|
103
|
+
expect(isFrozenAmendMirrorLine(mirrored, 'amend')).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it.each(['new', 'renew', 'cancel', null, undefined])('ignores the flag on a %s quote', (quoteType) => {
|
|
107
|
+
expect(isFrozenAmendMirrorLine(mirrored, quoteType)).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('ignores the flag on a line that is not a mirror line', () => {
|
|
111
|
+
expect(isFrozenAmendMirrorLine({ port_size: '1G', [ARC_PRICE_FROZEN_KEY]: true }, 'amend')).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('stripArcMarkers', () => {
|
|
116
|
+
it('removes every _arc* key and keeps the rest', () => {
|
|
117
|
+
expect(
|
|
118
|
+
stripArcMarkers({ port_size: '1G', _arcMirroredFromItemId: 'item-1', [ARC_PRICE_FROZEN_KEY]: true, [ARC_REPRICED_KEY]: true }),
|
|
119
|
+
).toEqual({ port_size: '1G' })
|
|
120
|
+
expect(stripArcMarkers(null)).toEqual({})
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('stripQuoteOnlyArcMarkers', () => {
|
|
125
|
+
it('removes the freeze and re-priced flags and keeps the descriptive markers', () => {
|
|
126
|
+
expect(
|
|
127
|
+
stripQuoteOnlyArcMarkers({
|
|
128
|
+
port_size: '1G',
|
|
129
|
+
_arcMirroredFromItemId: 'item-1',
|
|
130
|
+
[ARC_PRICE_FROZEN_KEY]: true,
|
|
131
|
+
[ARC_REPRICED_KEY]: true,
|
|
132
|
+
}),
|
|
133
|
+
).toEqual({ port_size: '1G', _arcMirroredFromItemId: 'item-1' })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('returns the input untouched when there is no quote-only marker', () => {
|
|
137
|
+
const cfg = { port_size: '1G' }
|
|
138
|
+
expect(stripQuoteOnlyArcMarkers(cfg)).toBe(cfg)
|
|
139
|
+
expect(stripQuoteOnlyArcMarkers(null)).toBeNull()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { withChargeWaterfall, withoutChargeWaterfall } from '../priceAdjustments/chargeWaterfall'
|
|
2
|
+
import type { ResolvedCharge } from '../types'
|
|
3
|
+
|
|
4
|
+
const base: ResolvedCharge = {
|
|
5
|
+
chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'per_unit',
|
|
6
|
+
unitPrice: 20, quantity: 4, totalPrice: 80, currencyCode: 'USD',
|
|
7
|
+
}
|
|
8
|
+
const rule = { ruleCode: 'volume', ruleName: 'Volume', ruleType: 'discount_percent', value: 20, unitPriceBefore: 25, unitPriceAfter: 20, delta: -5 }
|
|
9
|
+
it('derives pre-rule and standard money from legacy adjustments without mutating the snapshot', () => {
|
|
10
|
+
const input = { ...base, adjustments: [rule] }
|
|
11
|
+
const result = withChargeWaterfall(input)
|
|
12
|
+
expect(result).toMatchObject({ listUnitPrice: 25, listTotalPrice: 100, standardUnitPrice: 20, standardTotalPrice: 80 })
|
|
13
|
+
expect(result.adjustments?.[0].origin).toBe('rule')
|
|
14
|
+
expect(input.adjustments[0]).not.toHaveProperty('origin')
|
|
15
|
+
expect(result.unitPrice).toBe(base.unitPrice)
|
|
16
|
+
expect(result.totalPrice).toBe(base.totalPrice)
|
|
17
|
+
})
|
|
18
|
+
it('preserves exact tier totals and null usage totals', () => {
|
|
19
|
+
const tier = { ...base, unitPrice: 1 / 3, quantity: 3, totalPrice: 1 }
|
|
20
|
+
expect(withChargeWaterfall(tier).standardTotalPrice).toBe(1)
|
|
21
|
+
expect(withChargeWaterfall({ ...base, chargeType: 'usage', quantity: null, totalPrice: null })).toMatchObject({ listTotalPrice: null, standardTotalPrice: null })
|
|
22
|
+
})
|
|
23
|
+
it('keeps explicitly captured zero values', () => {
|
|
24
|
+
expect(withChargeWaterfall({ ...base, listUnitPrice: 0, listTotalPrice: 0 })).toMatchObject({ listUnitPrice: 0, listTotalPrice: 0 })
|
|
25
|
+
})
|
|
26
|
+
it('removes internal waterfall fields from the default public calculation response', () => {
|
|
27
|
+
expect(withoutChargeWaterfall(withChargeWaterfall(base))).toEqual(base)
|
|
28
|
+
})
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { CpqOrderLineConfiguration } from '../../data/entities'
|
|
1
2
|
import { DefaultCpqOrderService, OrderError } from '../cpqOrderService'
|
|
2
3
|
import type { TenantScope } from '../types'
|
|
3
4
|
|
|
4
5
|
interface MockEm {
|
|
6
|
+
fork: jest.MockedFunction<() => MockEm>
|
|
7
|
+
transactional: jest.MockedFunction<(callback: (em: MockEm) => Promise<unknown>) => Promise<unknown>>
|
|
5
8
|
findOne: jest.MockedFunction<(entity: unknown, where: Record<string, unknown>) => Promise<unknown>>
|
|
6
9
|
find: jest.MockedFunction<
|
|
7
10
|
(entity: unknown, where: Record<string, unknown>, options?: Record<string, unknown>) => Promise<unknown[]>
|
|
@@ -24,14 +27,19 @@ interface MockNumberGenerator {
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
function createMockEm(): MockEm {
|
|
27
|
-
|
|
30
|
+
const em: MockEm = {
|
|
31
|
+
fork: jest.fn(),
|
|
32
|
+
transactional: jest.fn(),
|
|
28
33
|
findOne: jest.fn(),
|
|
29
|
-
find: jest.fn(),
|
|
34
|
+
find: jest.fn().mockResolvedValue([]),
|
|
30
35
|
findAndCount: jest.fn(),
|
|
31
36
|
create: jest.fn().mockImplementation((_e, data: Record<string, unknown>) => ({ ...data })),
|
|
32
37
|
persist: jest.fn(),
|
|
33
38
|
flush: jest.fn().mockResolvedValue(undefined),
|
|
34
39
|
}
|
|
40
|
+
em.fork.mockReturnValue(em)
|
|
41
|
+
em.transactional.mockImplementation((callback) => callback(em))
|
|
42
|
+
return em
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
function createService(): {
|
|
@@ -601,3 +609,28 @@ describe('DefaultCpqOrderService.getOrder', () => {
|
|
|
601
609
|
expect(result.lines[0].offeringType).toBeNull()
|
|
602
610
|
})
|
|
603
611
|
})
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
describe('conversion stored-adjustment baseline', () => {
|
|
615
|
+
it.each([false, true])('converts valid stored offers and refuses invalid snapshots (invalid=%s)', async (invalid) => {
|
|
616
|
+
const { em, service } = createService()
|
|
617
|
+
const charges = [{ chargeCode: 'fee', chargeType: 'mrc', pricingMethod: 'flat', quantity: 1, unitPrice: 80, totalPrice: 80,
|
|
618
|
+
listUnitPrice: 120, listTotalPrice: 120, standardUnitPrice: 100, standardTotalPrice: 100, currencyCode: 'USD',
|
|
619
|
+
adjustmentErrors: invalid ? [{ adjustmentId: 'a', quoteLineId: 'line', code: 'ABOVE_STANDARD' }] : [] }]
|
|
620
|
+
em.findOne.mockResolvedValueOnce({ id: 'quote', quoteId: 'sales', cpqStatus: 'accepted', currencyCode: 'USD', customerId: 'customer', pricingSummary: { nrcTotal: 0, mrcTotal: 80 } })
|
|
621
|
+
.mockResolvedValueOnce(null) // no existing order
|
|
622
|
+
em.find.mockResolvedValueOnce([{ quoteLineId: 'line', quantity: 1, action: 'add', nrcTotal: '0', mrcTotal: '80', charges,
|
|
623
|
+
configuration: { _arcPriceFrozen: true, _arcRepriced: true }, offeringId: null }]).mockResolvedValueOnce([])
|
|
624
|
+
em.create.mockImplementation((_entity, values: Record<string, unknown>) => ({ createdAt: new Date(), ...values }))
|
|
625
|
+
if (invalid) {
|
|
626
|
+
await expect(service.convertQuoteToOrder('quote', SCOPE)).rejects.toMatchObject({ status: 422, message: 'ADJUSTMENT_INVALID', details: { lineId: 'line', code: 'ABOVE_STANDARD' } })
|
|
627
|
+
expect(em.create).not.toHaveBeenCalled()
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
await service.convertQuoteToOrder('quote', SCOPE)
|
|
631
|
+
expect(em.create).toHaveBeenCalledWith(CpqOrderLineConfiguration, expect.objectContaining({ charges, mrcTotal: '80' }))
|
|
632
|
+
const orderLine = em.create.mock.calls.find(([entity]) => entity === CpqOrderLineConfiguration)?.[1]
|
|
633
|
+
expect(orderLine?.configuration).not.toHaveProperty('_arcPriceFrozen')
|
|
634
|
+
expect(orderLine?.configuration).not.toHaveProperty('_arcRepriced')
|
|
635
|
+
})
|
|
636
|
+
})
|
|
@@ -1181,3 +1181,60 @@ describe('DefaultCpqPricingService — price rules application', () => {
|
|
|
1181
1181
|
expect(result.totalPrice).toBeNull()
|
|
1182
1182
|
})
|
|
1183
1183
|
})
|
|
1184
|
+
|
|
1185
|
+
/** Pin legacy money calculations before adding the list/standard waterfall. */
|
|
1186
|
+
describe('negotiated-pricing baseline — all rule types across charge models', () => {
|
|
1187
|
+
const models = ['flat', 'per_unit', 'volume', 'tiered'] as const
|
|
1188
|
+
const rules = [
|
|
1189
|
+
{ type: 'discount_percent', value: 10, total: 90 },
|
|
1190
|
+
{ type: 'discount_absolute', value: 20, total: 80 },
|
|
1191
|
+
{ type: 'surcharge_percent', value: 10, total: 110 },
|
|
1192
|
+
{ type: 'surcharge_absolute', value: 20, total: 120 },
|
|
1193
|
+
{ type: 'price_override', value: 20, total: 80 },
|
|
1194
|
+
{ type: 'discount_absolute', value: 200, total: 0 },
|
|
1195
|
+
{ type: 'discount_percent', value: 100, total: 0 },
|
|
1196
|
+
]
|
|
1197
|
+
it.each(models.flatMap((model) => rules.map((rule) => ({ model, ...rule }))))(
|
|
1198
|
+
'$model with $type $value preserves existing money and adjustment arithmetic',
|
|
1199
|
+
async ({ model, type, value, total }) => {
|
|
1200
|
+
const em = createMockEm()
|
|
1201
|
+
const charge = {
|
|
1202
|
+
...makeCharge({ code: 'fee', name: 'Fee', chargeType: 'mrc', pricingMethod: 'fixed' }),
|
|
1203
|
+
chargeModel: model,
|
|
1204
|
+
fixedPrice: model === 'flat' ? '100' : '25',
|
|
1205
|
+
quantityAttributeCode: 'units',
|
|
1206
|
+
...(model === 'volume' || model === 'tiered'
|
|
1207
|
+
? { pricingMethod: 'table', pricingTableId: 'prices', priceColumnKey: 'rate' } : {}),
|
|
1208
|
+
}
|
|
1209
|
+
em.findOne.mockResolvedValue({ id: 'prices', dimensions: [] })
|
|
1210
|
+
em.find.mockImplementation(async (entity) => {
|
|
1211
|
+
if (entity === CpqProductCharge) return [charge]
|
|
1212
|
+
if (entity === CpqPricingTableEntry) return [
|
|
1213
|
+
{ tierNumber: 0, rangeFrom: '1', rangeTo: '2', prices: { rate: 30 }, dimensionValues: {}, currencyCode: 'USD' },
|
|
1214
|
+
{ tierNumber: 1, rangeFrom: '3', rangeTo: null, prices: { rate: model === 'tiered' ? 20 : 25 }, dimensionValues: {}, currencyCode: 'USD' },
|
|
1215
|
+
]
|
|
1216
|
+
return [{ code: 'rule', name: 'Rule', ruleType: type, value: String(value) }]
|
|
1217
|
+
})
|
|
1218
|
+
const service = new DefaultCpqPricingService(em as unknown as never)
|
|
1219
|
+
const [result] = await service.resolveProductCharges({ offeringId: 'offer', configuration: { units: 4 }, currencyCode: 'USD', ...SCOPE })
|
|
1220
|
+
const quantity = model === 'flat' ? 1 : 4
|
|
1221
|
+
const expectedTotal = type === 'price_override' && model === 'flat' ? 20 : total
|
|
1222
|
+
expect(Math.round((result.totalPrice ?? 0) * 100)).toBe(expectedTotal * 100)
|
|
1223
|
+
expect(Math.round(result.unitPrice * quantity * 100)).toBe(expectedTotal * 100)
|
|
1224
|
+
expect(result.quantity).toBe(quantity)
|
|
1225
|
+
expect(result.currencyCode).toBe('USD')
|
|
1226
|
+
expect(result.listTotalPrice).toBe(100)
|
|
1227
|
+
expect(result.listUnitPrice).toBe(100 / quantity)
|
|
1228
|
+
expect(Math.round((result.standardTotalPrice ?? 0) * 100)).toBe(expectedTotal * 100)
|
|
1229
|
+
expect(result.standardUnitPrice).toBe(result.unitPrice)
|
|
1230
|
+
expect(result.adjustments?.[0].origin).toBe('rule')
|
|
1231
|
+
expect(result.adjustments).toHaveLength(1)
|
|
1232
|
+
expect(result.adjustments?.[0]).toMatchObject({ ruleCode: 'rule', ruleName: 'Rule', ruleType: type, value })
|
|
1233
|
+
expect(Math.round((result.adjustments?.[0].unitPriceBefore ?? 0) * quantity * 100)).toBe(10000)
|
|
1234
|
+
expect(Math.round((result.adjustments?.[0].delta ?? 0) * quantity * 100)).toBe((expectedTotal - 100) * 100)
|
|
1235
|
+
if (model === 'tiered') {
|
|
1236
|
+
expect(result.breakdown?.tiers.map((tier) => Math.round(tier.subtotal * 100))).toEqual([6000, 4000])
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
)
|
|
1240
|
+
})
|