@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
|
@@ -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,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
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { DefaultCpqQuotingService } from '../cpqQuotingService'
|
|
3
|
+
import { CpqProductOffering, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
4
|
+
import { SalesQuote, SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
5
|
+
const scope = { tenantId: randomUUID(), organizationId: randomUUID() }
|
|
6
|
+
|
|
7
|
+
it.each([false, true])('clones with remapped adjustments and safe pricing markers (ARC source=%s)', async (arc) => {
|
|
8
|
+
const quoteId = randomUUID(), salesId = randomUUID(), lineId = randomUUID(), userId = randomUUID()
|
|
9
|
+
const charge = { chargeCode: 'fee', chargeName: 'Fee', chargeType: 'mrc', pricingMethod: 'flat', unitPrice: 100, totalPrice: 100, quantity: 1, currencyCode: 'USD', adjustments: [] }
|
|
10
|
+
const original = { id: quoteId, quoteId: salesId, customerId: userId, cpqStatus: 'ready', quoteType: arc ? 'amend' : 'new', version: 1, pricingRevision: 0, currencyCode: 'USD', ...scope }
|
|
11
|
+
const line = { id: randomUUID(), quoteLineId: lineId, quoteConfigurationId: quoteId, offeringId: 'offer', productId: null, parentLineId: null, quantity: 1, configuration: arc ? { _arcPriceFrozen: true, _arcMirroredFromItemId: randomUUID() } : {}, charges: [charge], mrcTotal: '100', nrcTotal: '0', isConfigured: true, deletedAt: null, ...scope }
|
|
12
|
+
const rows = new Map<unknown, Array<Record<string, unknown>>>([
|
|
13
|
+
[CpqQuoteConfiguration, [original]], [CpqQuoteLineConfiguration, [line]],
|
|
14
|
+
[SalesQuote, [{ id: salesId, quoteNumber: 'Q-1', customerEntityId: userId, currencyCode: 'USD', ...scope }]],
|
|
15
|
+
[SalesQuoteLine, [{ id: lineId, lineNumber: 1, quantity: '1', ...scope }]],
|
|
16
|
+
[CpqQuotePriceAdjustment, arc ? [] : [{ id: randomUUID(), quoteConfigurationId: quoteId, quoteId: salesId, quoteLineId: lineId, level: 'charge', chargeCode: 'fee', chargeTypeFilter: null, adjustmentType: 'price_override', value: '80', currencyCode: 'USD', reasonCode: 'inactive_reason', reasonNote: null, windowKind: 'relative', fromMonth: 1, toMonth: null, createdByUserId: userId, deletedAt: null, isActive: true, ...scope }]],
|
|
17
|
+
])
|
|
18
|
+
const matches = (row: Record<string, unknown>, where: Record<string, unknown>) => Object.entries(where).every(([key, value]) =>
|
|
19
|
+
value === null ? row[key] == null : typeof value === 'object' ? true : row[key] === value)
|
|
20
|
+
const em = {
|
|
21
|
+
fork: jest.fn(), transactional: jest.fn(), persist: jest.fn(), flush: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
assign: jest.fn((entity: object, values: object) => Object.assign(entity, values)),
|
|
23
|
+
create: jest.fn((entity: unknown, values: Record<string, unknown>) => {
|
|
24
|
+
const row = { id: randomUUID(), quoteType: 'new', createdAt: new Date(), updatedAt: new Date(), deletedAt: null, ...values }
|
|
25
|
+
const group = rows.get(entity) ?? []; group.push(row); rows.set(entity, group); return row
|
|
26
|
+
}),
|
|
27
|
+
findOne: jest.fn(async (entity: unknown, where: Record<string, unknown>) => entity === CpqProductOffering ? { id: 'offer', name: 'Offering', offeringType: 'simple' } : (rows.get(entity) ?? []).find((row) => matches(row, where)) ?? null),
|
|
28
|
+
find: jest.fn(async (entity: unknown, where: Record<string, unknown>) => (rows.get(entity) ?? []).filter((row) => matches(row, where))),
|
|
29
|
+
}
|
|
30
|
+
em.fork.mockReturnValue(em)
|
|
31
|
+
em.transactional.mockImplementation(async (execute: (tx: typeof em) => Promise<unknown>) => execute(em))
|
|
32
|
+
const service = new DefaultCpqQuotingService({ em: em as never,
|
|
33
|
+
cpqPricingService: { resolveProductCharges: async () => [charge] } as never,
|
|
34
|
+
cpqValidationService: { validateOfferingConfiguration: async () => ({ errors: [] }), validateRelationships: async () => [] } as never,
|
|
35
|
+
cpqProductService: {} as never, salesDocumentNumberGenerator: { generate: async () => ({ number: 'Q-2' }) } as never })
|
|
36
|
+
const result = await service.cloneQuote(quoteId, scope)
|
|
37
|
+
expect(result.id).not.toBe(quoteId)
|
|
38
|
+
expect(result.lines[0].lineId).not.toBe(lineId)
|
|
39
|
+
expect(result.lines[0].configuration).not.toHaveProperty('_arcPriceFrozen')
|
|
40
|
+
expect(result.lines[0].charges[0].totalPrice).toBe(arc ? 100 : 80)
|
|
41
|
+
if (!arc) {
|
|
42
|
+
const copied = (rows.get(CpqQuotePriceAdjustment) ?? []).find((row) => row.quoteConfigurationId === result.id)
|
|
43
|
+
expect(copied).toMatchObject({ quoteLineId: result.lines[0].lineId, value: '80', reasonCode: 'inactive_reason' })
|
|
44
|
+
} else expect(line.configuration).toHaveProperty('_arcPriceFrozen', true)
|
|
45
|
+
})
|