@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,109 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
2
|
+
import { join, relative } from 'path'
|
|
3
|
+
import ts from 'typescript'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static source scans backing the CPQ i18n guardrails
|
|
7
|
+
* (spec .ai/specs/2026-11-15-cpq-multi-language.md):
|
|
8
|
+
*
|
|
9
|
+
* - `collectTranslationKeys` — every literal `t('cpq.…')` key and every
|
|
10
|
+
* template-literal key prefix (`t(\`cpq.x.${v}\`)`) used in source;
|
|
11
|
+
* - `collectHardcodedStrings` — user-facing English literals that bypass
|
|
12
|
+
* `t()`: JSX text, string-valued UI attributes, and `label` / `title` /
|
|
13
|
+
* `header` / `placeholder` / `description` properties in UI files.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MODULE_ROOT = join(__dirname, '..', '..')
|
|
17
|
+
const SKIP_DIRS = new Set(['__tests__', '__integration__', 'migrations', 'i18n', 'node_modules'])
|
|
18
|
+
/** Directories whose files render UI. */
|
|
19
|
+
export const UI_DIRS = ['backend', 'components', 'widgets', 'workflows']
|
|
20
|
+
|
|
21
|
+
export function listSourceFiles(dir: string, exts = /\.(tsx?|mts)$/): string[] {
|
|
22
|
+
const out: string[] = []
|
|
23
|
+
for (const entry of readdirSync(dir)) {
|
|
24
|
+
const full = join(dir, entry)
|
|
25
|
+
if (statSync(full).isDirectory()) {
|
|
26
|
+
if (!SKIP_DIRS.has(entry)) out.push(...listSourceFiles(full, exts))
|
|
27
|
+
} else if (exts.test(entry) && !/\.test\.tsx?$/.test(entry)) {
|
|
28
|
+
out.push(full)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parse(file: string): ts.SourceFile {
|
|
35
|
+
return ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function literalText(node: ts.Node | undefined): string | null {
|
|
39
|
+
if (!node) return null
|
|
40
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TRANSLATE_CALLEES = new Set(['t', 'translate', 'tr'])
|
|
45
|
+
|
|
46
|
+
export function collectTranslationKeys(files: string[]): { keys: Set<string>; prefixes: Set<string> } {
|
|
47
|
+
const keys = new Set<string>()
|
|
48
|
+
const prefixes = new Set<string>()
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const visit = (node: ts.Node) => {
|
|
51
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && TRANSLATE_CALLEES.has(node.expression.text)) {
|
|
52
|
+
const first = node.arguments[0]
|
|
53
|
+
const key = literalText(first)
|
|
54
|
+
if (key?.startsWith('cpq.')) keys.add(key)
|
|
55
|
+
if (first && ts.isTemplateExpression(first) && first.head.text.startsWith('cpq.')) prefixes.add(first.head.text)
|
|
56
|
+
}
|
|
57
|
+
// `*Key` props (page meta, breadcrumbs) and injection-table `groupLabel`s the host translates.
|
|
58
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && /(Key|^groupLabel)$/.test(node.name.text)) {
|
|
59
|
+
const key = literalText(node.initializer)
|
|
60
|
+
if (key?.startsWith('cpq.')) keys.add(key)
|
|
61
|
+
}
|
|
62
|
+
ts.forEachChild(node, visit)
|
|
63
|
+
}
|
|
64
|
+
visit(parse(file))
|
|
65
|
+
}
|
|
66
|
+
return { keys, prefixes }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const UI_ATTRIBUTES = new Set(['label', 'placeholder', 'title', 'aria-label', 'description', 'emptyLabel', 'emptyMessage', 'alt'])
|
|
70
|
+
const UI_PROPERTIES = new Set(['label', 'title', 'header', 'placeholder', 'description', 'emptyLabel', 'emptyMessage'])
|
|
71
|
+
/** Natural-language text: two letters in a row, not a code-ish token (snake_case, dotted, kebab, path, CSS). */
|
|
72
|
+
function isProse(text: string): boolean {
|
|
73
|
+
const value = text.trim()
|
|
74
|
+
if (!/[A-Za-z]{2}/.test(value)) return false
|
|
75
|
+
if (/^[a-z0-9]+([._/-][a-z0-9]+)+$/i.test(value) && !/\s/.test(value)) return false
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type HardcodedHit = { file: string; line: number; text: string }
|
|
80
|
+
|
|
81
|
+
export function collectHardcodedStrings(files: string[]): HardcodedHit[] {
|
|
82
|
+
const hits: HardcodedHit[] = []
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
const sf = parse(file)
|
|
85
|
+
const rel = relative(MODULE_ROOT, file)
|
|
86
|
+
const hit = (node: ts.Node, text: string) =>
|
|
87
|
+
hits.push({ file: rel, line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1, text: text.trim().slice(0, 80) })
|
|
88
|
+
const visit = (node: ts.Node) => {
|
|
89
|
+
if (ts.isJsxText(node) && isProse(node.text)) hit(node, node.text)
|
|
90
|
+
if (ts.isJsxAttribute(node) && UI_ATTRIBUTES.has(node.name.getText())) {
|
|
91
|
+
const init = node.initializer
|
|
92
|
+
const text = init && ts.isStringLiteral(init) ? init.text : init && ts.isJsxExpression(init) ? literalText(init.expression) : null
|
|
93
|
+
if (text && isProse(text)) hit(node, text)
|
|
94
|
+
}
|
|
95
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && UI_PROPERTIES.has(node.name.text)) {
|
|
96
|
+
const text = literalText(node.initializer)
|
|
97
|
+
// `{ label: 'X', labelKey: 'cpq.x' }` (page meta, breadcrumbs) is translated by the host.
|
|
98
|
+
const parent = node.parent
|
|
99
|
+
const hasKeySibling =
|
|
100
|
+
ts.isObjectLiteralExpression(parent) &&
|
|
101
|
+
parent.properties.some((p) => ts.isPropertyAssignment(p) && p.name.getText() === `${node.name.getText()}Key`)
|
|
102
|
+
if (text && isProse(text) && !hasKeySibling) hit(node, text)
|
|
103
|
+
}
|
|
104
|
+
ts.forEachChild(node, visit)
|
|
105
|
+
}
|
|
106
|
+
visit(sf)
|
|
107
|
+
}
|
|
108
|
+
return hits
|
|
109
|
+
}
|
package/src/modules/cpq/acl.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export const features = [
|
|
2
|
+
{ id: 'cpq.quotes.adjust_price', title: 'Adjust CPQ quote prices', module: 'cpq' },
|
|
2
3
|
{ id: 'cpq.quotes.view', title: 'View CPQ Quotes', module: 'cpq' },
|
|
3
4
|
{ id: 'cpq.quotes.manage', title: 'Manage CPQ Quotes', module: 'cpq' },
|
|
4
5
|
{ id: 'cpq.pricing.view', title: 'View Pricing', module: 'cpq' },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const execute = jest.fn(), list = jest.fn(), preview = jest.fn(), guards = jest.fn(), after = jest.fn()
|
|
2
|
+
const ctx = { auth: { sub: 'user', tenantId: 'tenant' }, tenantId: 'tenant', organizationId: 'org', organizationScope: null,
|
|
3
|
+
container: { resolve: (name: string) => name === 'commandBus' ? { execute } : { list, preview } } }
|
|
4
|
+
jest.mock('../context', () => ({ resolveCpqRouteContext: jest.fn(async () => ctx) }))
|
|
5
|
+
jest.mock('../_helpers/quoteMutationGuard', () => ({ runCpqMutationGuards: (...args: unknown[]) => guards(...args) }))
|
|
6
|
+
import { GET, POST, metadata } from '../quotes/[quoteId]/price-adjustments/route'
|
|
7
|
+
import { POST as PREVIEW } from '../quotes/[quoteId]/price-adjustments/preview/route'
|
|
8
|
+
import { PATCH, DELETE } from '../quotes/[quoteId]/price-adjustments/[adjustmentId]/route'
|
|
9
|
+
import { QuotingError } from '../../services/cpqQuotingService'
|
|
10
|
+
const quoteId = '11111111-1111-4111-8111-111111111111', adjustmentId = '22222222-2222-4222-8222-222222222222'
|
|
11
|
+
const params = { params: Promise.resolve({ quoteId, adjustmentId }) }
|
|
12
|
+
const input = { level: 'charge', quoteLineId: quoteId, chargeCode: 'fee', adjustmentType: 'price_override', value: 80, currencyCode: 'USD', reasonCode: 'competitive_match' }
|
|
13
|
+
function request(method: string, body?: unknown, query = '') { return new Request(`http://localhost/api/cpq/quotes/${quoteId}/price-adjustments${query}`, { method, headers: { 'Content-Type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) }
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
jest.clearAllMocks()
|
|
16
|
+
guards.mockResolvedValue({ ok: true, runAfterSuccess: after })
|
|
17
|
+
execute.mockResolvedValue({ result: { adjustment: { id: adjustmentId }, quote: { id: quoteId } }, logEntry: null })
|
|
18
|
+
preview.mockResolvedValue({ quote: { id: quoteId }, errors: [] })
|
|
19
|
+
list.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 25, pricingRevision: 1 })
|
|
20
|
+
})
|
|
21
|
+
it('requires view on GET and both immutable manage/adjust features on POST', () => {
|
|
22
|
+
expect(metadata.GET.requireFeatures).toEqual(['cpq.quotes.view'])
|
|
23
|
+
expect(metadata.POST.requireFeatures).toEqual(['cpq.quotes.manage', 'cpq.quotes.adjust_price'])
|
|
24
|
+
})
|
|
25
|
+
it('creates through the command bus with server scope and guard hooks', async () => {
|
|
26
|
+
const response = await POST(request('POST', input), params)
|
|
27
|
+
expect(response.status).toBe(201)
|
|
28
|
+
expect(execute).toHaveBeenCalledWith('cpq.quote_price_adjustment.create', expect.objectContaining({ input: { quoteId, adjustment: input }, ctx: expect.objectContaining({ selectedOrganizationId: 'org', auth: ctx.auth }) }))
|
|
29
|
+
expect(guards).toHaveBeenCalledWith(ctx, expect.any(Request), expect.objectContaining({ resourceKind: 'cpq.quote_price_adjustment', operation: 'create', mutationPayload: { quoteId, ...input } }))
|
|
30
|
+
expect(after).toHaveBeenCalledTimes(1)
|
|
31
|
+
expect(execute.mock.invocationCallOrder[0]).toBeLessThan(after.mock.invocationCallOrder[0])
|
|
32
|
+
})
|
|
33
|
+
it('applies guard modifications but never replaces quote path identity', async () => {
|
|
34
|
+
guards.mockResolvedValue({ ok: true, modifiedPayload: { quoteId: adjustmentId, value: 70 }, runAfterSuccess: after })
|
|
35
|
+
await POST(request('POST', input), params)
|
|
36
|
+
expect(execute.mock.calls[0][1].input).toEqual({ quoteId, adjustment: { ...input, value: 70 } })
|
|
37
|
+
})
|
|
38
|
+
it('refuses invalid guard modifications and guard rejections before the command', async () => {
|
|
39
|
+
guards.mockResolvedValue({ ok: true, modifiedPayload: { value: -1 }, runAfterSuccess: after })
|
|
40
|
+
expect((await POST(request('POST', input), params)).status).toBe(400)
|
|
41
|
+
guards.mockResolvedValue({ ok: false, response: Response.json({ error: 'Approval required' }, { status: 423 }) })
|
|
42
|
+
expect((await POST(request('POST', input), params)).status).toBe(423)
|
|
43
|
+
expect(execute).not.toHaveBeenCalled()
|
|
44
|
+
expect(after).not.toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
it.each([403, 404, 409, 422])('preserves command error %s without after-success', async (status) => {
|
|
47
|
+
execute.mockRejectedValue(new QuotingError(status, 'Rejected'))
|
|
48
|
+
const response = await POST(request('POST', input), params)
|
|
49
|
+
expect(response.status).toBe(status)
|
|
50
|
+
expect(await response.json()).toEqual({ error: 'Rejected' })
|
|
51
|
+
expect(after).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
it('updates immutable targets through PATCH and deletes with a revision token', async () => {
|
|
54
|
+
expect((await PATCH(request('PATCH', { value: 70 }), params)).status).toBe(200)
|
|
55
|
+
expect(execute.mock.calls[0][1].input).toEqual({ quoteId, adjustmentId, patch: { value: 70 } })
|
|
56
|
+
expect((await DELETE(request('DELETE', undefined, '?expectedRevision=3'), params)).status).toBe(200)
|
|
57
|
+
expect(execute.mock.calls[1][1].input).toEqual({ quoteId, adjustmentId, expectedRevision: 3 })
|
|
58
|
+
expect((await PATCH(request('PATCH', { quoteLineId: adjustmentId }), params)).status).toBe(400)
|
|
59
|
+
})
|
|
60
|
+
it('lists with bounded pagination and rejects malformed bodies and identifiers', async () => {
|
|
61
|
+
expect((await GET(request('GET', undefined, '?page=2&pageSize=10'), params)).status).toBe(200)
|
|
62
|
+
expect(list).toHaveBeenCalledWith(quoteId, { tenantId: 'tenant', organizationId: 'org' }, 2, 10)
|
|
63
|
+
expect((await GET(request('GET', undefined, '?pageSize=101'), params)).status).toBe(400)
|
|
64
|
+
expect((await POST(request('POST', {}), params)).status).toBe(400)
|
|
65
|
+
expect((await POST(request('POST', input), { params: Promise.resolve({ quoteId: 'bad' }) })).status).toBe(400)
|
|
66
|
+
})
|
|
67
|
+
it('returns the framework undo operation metadata for a logged command', async () => {
|
|
68
|
+
execute.mockResolvedValue({ result: { adjustment: { id: adjustmentId }, quote: { id: quoteId } },
|
|
69
|
+
logEntry: { id: 'log', commandId: 'cpq.quote_price_adjustment.create', undoToken: 'undo', createdAt: new Date() } })
|
|
70
|
+
expect((await POST(request('POST', input), params)).headers.get('x-om-operation')).toBeTruthy()
|
|
71
|
+
})
|
|
72
|
+
it('previews through the read-only service without commands or mutation hooks', async () => {
|
|
73
|
+
const response = await PREVIEW(request('POST', { draft: input }), params)
|
|
74
|
+
expect(response.status).toBe(200)
|
|
75
|
+
expect(preview).toHaveBeenCalledWith(quoteId, input, undefined, { tenantId: 'tenant', organizationId: 'org' })
|
|
76
|
+
expect(execute).not.toHaveBeenCalled()
|
|
77
|
+
expect(guards).not.toHaveBeenCalled()
|
|
78
|
+
expect((await PREVIEW(request('POST', { draft: {} }), params)).status).toBe(400)
|
|
79
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { OrderError } from '../../services/cpqOrderService'
|
|
2
|
+
const addQuoteItem = jest.fn()
|
|
3
|
+
const updateQuoteItem = jest.fn()
|
|
4
|
+
const removeQuoteItem = jest.fn()
|
|
5
|
+
const convertQuoteToOrder = jest.fn()
|
|
6
|
+
const runCpqMutationGuards = jest.fn()
|
|
7
|
+
const runAfterSuccess = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.mock('../context', () => ({
|
|
10
|
+
resolveCpqRouteContext: jest.fn().mockImplementation(async () => ({
|
|
11
|
+
auth: { sub: 'user-1' }, organizationId: 'org-1', tenantId: 'tenant-1',
|
|
12
|
+
container: { resolve: () => ({ convertQuoteToOrder }) },
|
|
13
|
+
})),
|
|
14
|
+
}))
|
|
15
|
+
jest.mock('../resolveQuotingService', () => ({
|
|
16
|
+
resolveQuotingService: () => ({ addQuoteItem, updateQuoteItem, removeQuoteItem }),
|
|
17
|
+
}))
|
|
18
|
+
jest.mock('../_helpers/quoteMutationGuard', () => ({
|
|
19
|
+
CPQ_QUOTE_RESOURCE_KIND: 'cpq.quote',
|
|
20
|
+
CPQ_QUOTE_LINE_RESOURCE_KIND: 'cpq.quote_line',
|
|
21
|
+
runCpqMutationGuards: (...args: unknown[]) => runCpqMutationGuards(...args),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
import { POST as add } from '../quotes/[quoteId]/items/route'
|
|
25
|
+
import { PUT as update, DELETE as remove } from '../quotes/[quoteId]/items/[lineId]/route'
|
|
26
|
+
import { POST as convert } from '../orders/route'
|
|
27
|
+
|
|
28
|
+
const quoteId = '11111111-1111-4111-8111-111111111111'
|
|
29
|
+
const lineId = '22222222-2222-4222-8222-222222222222'
|
|
30
|
+
const scope = { organizationId: 'org-1', tenantId: 'tenant-1' }
|
|
31
|
+
const params = { params: Promise.resolve({ quoteId, lineId }) }
|
|
32
|
+
function request(method: string, body?: unknown) {
|
|
33
|
+
return new Request('http://localhost/api/cpq/quotes', {
|
|
34
|
+
method, headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
const cases = [
|
|
39
|
+
{ name: 'add', invoke: () => add(request('POST', { configuration: {}, quantity: 1 }), params), service: addQuoteItem, operation: 'create', resourceKind: 'cpq.quote_line', status: 201 },
|
|
40
|
+
{ name: 'update', invoke: () => update(request('PUT', { configuration: {}, quantity: 1 }), params), service: updateQuoteItem, operation: 'update', resourceKind: 'cpq.quote_line', status: 200 },
|
|
41
|
+
{ name: 'remove', invoke: () => remove(request('DELETE'), params), service: removeQuoteItem, operation: 'delete', resourceKind: 'cpq.quote_line', status: 200 },
|
|
42
|
+
{ name: 'convert', invoke: () => convert(request('POST', { quoteId })), service: convertQuoteToOrder, operation: 'custom', resourceKind: 'cpq.quote', status: 201 },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
jest.clearAllMocks()
|
|
47
|
+
for (const entry of cases) entry.service.mockResolvedValue({ id: quoteId })
|
|
48
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe.each(cases)('$name quote write guards', ({ invoke, service, operation, resourceKind, status }) => {
|
|
52
|
+
it('consults the guard before writing and invokes after-success after writing', async () => {
|
|
53
|
+
expect((await invoke()).status).toBe(status)
|
|
54
|
+
expect(runCpqMutationGuards).toHaveBeenCalledWith(expect.anything(), expect.any(Request),
|
|
55
|
+
expect.objectContaining({ operation, resourceKind }))
|
|
56
|
+
expect(service).toHaveBeenCalledTimes(1)
|
|
57
|
+
expect(runAfterSuccess).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(runCpqMutationGuards.mock.invocationCallOrder[0]).toBeLessThan(service.mock.invocationCallOrder[0])
|
|
59
|
+
expect(service.mock.invocationCallOrder[0]).toBeLessThan(runAfterSuccess.mock.invocationCallOrder[0])
|
|
60
|
+
})
|
|
61
|
+
it('returns a guard rejection without writing or calling after-success', async () => {
|
|
62
|
+
runCpqMutationGuards.mockResolvedValue({ ok: false, response: Response.json({ error: 'Approval required' }, { status: 423 }) })
|
|
63
|
+
const response = await invoke()
|
|
64
|
+
expect(response.status).toBe(423)
|
|
65
|
+
expect(await response.json()).toEqual({ error: 'Approval required' })
|
|
66
|
+
expect(service).not.toHaveBeenCalled()
|
|
67
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
it('does not call after-success if the write fails', async () => {
|
|
70
|
+
const log = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
71
|
+
try {
|
|
72
|
+
service.mockRejectedValueOnce(new Error('Write failed'))
|
|
73
|
+
expect((await invoke()).status).toBe(500)
|
|
74
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
75
|
+
} finally { log.mockRestore() }
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it.each(cases.slice(0, 2))('$name validates and applies guard-modified fields', async ({ invoke, service, name }) => {
|
|
80
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, modifiedPayload: { quantity: 3 }, runAfterSuccess })
|
|
81
|
+
await invoke()
|
|
82
|
+
const args = name === 'add'
|
|
83
|
+
? [quoteId, expect.objectContaining({ quantity: 3 }), scope]
|
|
84
|
+
: [quoteId, lineId, expect.objectContaining({ quantity: 3 }), scope]
|
|
85
|
+
expect(service).toHaveBeenCalledWith(...args)
|
|
86
|
+
})
|
|
87
|
+
it.each(cases.slice(0, 2))('$name refuses invalid guard-modified fields before writing', async ({ invoke, service }) => {
|
|
88
|
+
runCpqMutationGuards.mockResolvedValue({ ok: true, modifiedPayload: { quantity: 0 }, runAfterSuccess })
|
|
89
|
+
expect((await invoke()).status).toBe(400)
|
|
90
|
+
expect(service).not.toHaveBeenCalled()
|
|
91
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns the invalid adjustment line/code from conversion without running after-success', async () => {
|
|
95
|
+
convertQuoteToOrder.mockRejectedValueOnce(new OrderError(422, 'ADJUSTMENT_INVALID', { lineId, code: 'ABOVE_STANDARD' }))
|
|
96
|
+
const response = await convert(request('POST', { quoteId }))
|
|
97
|
+
expect(response.status).toBe(422)
|
|
98
|
+
expect(await response.json()).toEqual({ error: 'ADJUSTMENT_INVALID', lineId, code: 'ABOVE_STANDARD' })
|
|
99
|
+
expect(runAfterSuccess).not.toHaveBeenCalled()
|
|
100
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const runRouteMutationGuards = jest.fn()
|
|
2
|
+
|
|
3
|
+
jest.mock('@open-mercato/shared/lib/crud/route-mutation-guard', () => ({
|
|
4
|
+
runRouteMutationGuards: (...args: unknown[]) => runRouteMutationGuards(...args),
|
|
5
|
+
}))
|
|
6
|
+
|
|
7
|
+
import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../quoteMutationGuard'
|
|
8
|
+
|
|
9
|
+
describe('runCpqMutationGuards', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
runRouteMutationGuards.mockReset()
|
|
12
|
+
runRouteMutationGuards.mockResolvedValue({ ok: true, runAfterSuccess: jest.fn() })
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('forwards the route actor, tenant scope and input to the framework guard runner', async () => {
|
|
16
|
+
const container = { resolve: jest.fn() }
|
|
17
|
+
const req = new Request('http://localhost/api/cpq/quotes/q-1/status', { method: 'POST' })
|
|
18
|
+
const input = {
|
|
19
|
+
resourceKind: CPQ_QUOTE_RESOURCE_KIND,
|
|
20
|
+
resourceId: 'q-1',
|
|
21
|
+
operation: 'custom' as const,
|
|
22
|
+
mutationPayload: { action: 'transition_status', targetStatus: 'with_customer' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await runCpqMutationGuards(
|
|
26
|
+
{ container: container as never, auth: { sub: 'user-1' } as never, tenantId: 't-1', organizationId: 'org-1' },
|
|
27
|
+
req,
|
|
28
|
+
input,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(runRouteMutationGuards).toHaveBeenCalledWith({
|
|
32
|
+
container,
|
|
33
|
+
req,
|
|
34
|
+
auth: { userId: 'user-1', tenantId: 't-1', organizationId: 'org-1' },
|
|
35
|
+
input,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('passes a null organization when the route has none selected', async () => {
|
|
40
|
+
await runCpqMutationGuards(
|
|
41
|
+
{ container: {} as never, auth: { sub: 'user-1' } as never, tenantId: 't-1', organizationId: '' },
|
|
42
|
+
new Request('http://localhost/x', { method: 'POST' }),
|
|
43
|
+
{ resourceKind: CPQ_QUOTE_RESOURCE_KIND },
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(runRouteMutationGuards.mock.calls[0][0].auth.organizationId).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
4
|
+
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
5
|
+
import type { CpqRouteContext } from '../context'
|
|
6
|
+
import { runCpqMutationGuards } from './quoteMutationGuard'
|
|
7
|
+
import type { AdjustmentWriteResult } from '../../services/priceAdjustments/adjustmentService'
|
|
8
|
+
|
|
9
|
+
export async function adjustmentCommandRoute<T extends object>(
|
|
10
|
+
req: Request, ctx: CpqRouteContext, quoteId: string, body: T,
|
|
11
|
+
schema: z.ZodType<T>, operation: 'create' | 'update' | 'delete', adjustmentId?: string,
|
|
12
|
+
): Promise<NextResponse | Response> {
|
|
13
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
14
|
+
resourceKind: 'cpq.quote_price_adjustment', resourceId: adjustmentId,
|
|
15
|
+
operation, mutationPayload: { quoteId, ...body },
|
|
16
|
+
})
|
|
17
|
+
if (!guard.ok) return guard.response
|
|
18
|
+
// Path identifiers belong to the server, even when a project guard returns
|
|
19
|
+
// the full payload including the original quoteId metadata.
|
|
20
|
+
const modified = Object.fromEntries(Object.entries(guard.modifiedPayload ?? {})
|
|
21
|
+
.filter(([key]) => key !== 'quoteId' && key !== 'adjustmentId'))
|
|
22
|
+
const effective = schema.parse({ ...body, ...modified })
|
|
23
|
+
const input = operation === 'create' ? { quoteId, adjustment: effective }
|
|
24
|
+
: operation === 'update' ? { quoteId, adjustmentId, patch: effective }
|
|
25
|
+
: { quoteId, adjustmentId, ...effective }
|
|
26
|
+
const commandCtx: CommandRuntimeContext = {
|
|
27
|
+
container: ctx.container, auth: ctx.auth, organizationScope: ctx.organizationScope,
|
|
28
|
+
selectedOrganizationId: ctx.organizationId, organizationIds: [ctx.organizationId], request: req,
|
|
29
|
+
}
|
|
30
|
+
const bus = ctx.container.resolve<CommandBus>('commandBus')
|
|
31
|
+
const commandId = `cpq.quote_price_adjustment.${operation}`
|
|
32
|
+
const { result, logEntry } = await bus.execute<typeof input, AdjustmentWriteResult>(commandId, { input, ctx: commandCtx })
|
|
33
|
+
if (!result) throw new Error('Adjustment command did not return a result')
|
|
34
|
+
await guard.runAfterSuccess()
|
|
35
|
+
const response = NextResponse.json(operation === 'delete' ? { quote: result.quote }
|
|
36
|
+
: { adjustment: result.adjustment, quote: result.quote }, { status: operation === 'create' ? 201 : 200 })
|
|
37
|
+
if (logEntry?.undoToken && logEntry.id && logEntry.commandId) {
|
|
38
|
+
response.headers.set('x-om-operation', serializeOperationMetadata({ id: logEntry.id, undoToken: logEntry.undoToken,
|
|
39
|
+
commandId: logEntry.commandId, actionLabel: logEntry.actionLabel ?? null,
|
|
40
|
+
resourceKind: 'cpq.quote_price_adjustment', resourceId: result.adjustment.id,
|
|
41
|
+
executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString()
|
|
42
|
+
: typeof logEntry.createdAt === 'string' ? logEntry.createdAt : new Date().toISOString() }))
|
|
43
|
+
}
|
|
44
|
+
return response
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runRouteMutationGuards,
|
|
3
|
+
type RouteMutationGuardInput,
|
|
4
|
+
type RouteMutationGuardResult,
|
|
5
|
+
} from '@open-mercato/shared/lib/crud/route-mutation-guard'
|
|
6
|
+
import type { CpqRouteContext } from '../context'
|
|
7
|
+
|
|
8
|
+
/** Mutation-guard resource kinds for CPQ quote writes (`module.entity`). */
|
|
9
|
+
export const CPQ_QUOTE_RESOURCE_KIND = 'cpq.quote'
|
|
10
|
+
export const CPQ_QUOTE_LINE_RESOURCE_KIND = 'cpq.quote_line'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run every registered mutation guard for a CPQ custom write route.
|
|
14
|
+
*
|
|
15
|
+
* Quote routes are hand-written (not `makeCrudRoute`), so they wire guards
|
|
16
|
+
* explicitly through the framework's registry-based wrapper. Projects use
|
|
17
|
+
* this hook to enforce rules CPQ does not own — e.g. blocking
|
|
18
|
+
* `ready → with_customer` or order conversion until an approval workflow
|
|
19
|
+
* signs a discounted quote off. With no guard registered the call passes.
|
|
20
|
+
*
|
|
21
|
+
* Call `runAfterSuccess()` on the passed result only after the write commits.
|
|
22
|
+
*/
|
|
23
|
+
export function runCpqMutationGuards(
|
|
24
|
+
ctx: Pick<CpqRouteContext, 'container' | 'auth' | 'tenantId' | 'organizationId'>,
|
|
25
|
+
req: Request,
|
|
26
|
+
input: RouteMutationGuardInput,
|
|
27
|
+
): Promise<RouteMutationGuardResult> {
|
|
28
|
+
return runRouteMutationGuards({
|
|
29
|
+
container: ctx.container,
|
|
30
|
+
req,
|
|
31
|
+
auth: {
|
|
32
|
+
userId: ctx.auth?.sub ?? '',
|
|
33
|
+
tenantId: ctx.tenantId,
|
|
34
|
+
organizationId: ctx.organizationId || null,
|
|
35
|
+
},
|
|
36
|
+
input,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { OpenApiResponseDoc } from '@open-mercato/shared/lib/openapi'
|
|
3
|
+
|
|
4
|
+
/** Error body every CPQ quote route returns (`{ error, ... }`). */
|
|
5
|
+
export const cpqErrorResponseSchema = z.object({ error: z.string() }).passthrough()
|
|
6
|
+
|
|
7
|
+
export function cpqErrorDoc(status: number, description: string): OpenApiResponseDoc {
|
|
8
|
+
return { status, description, schema: cpqErrorResponseSchema }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const cpqQuoteIdPathParams = z.object({ quoteId: z.string().uuid() })
|
|
12
|
+
|
|
13
|
+
/** Shared error docs for quote writes guarded by mutation guards. */
|
|
14
|
+
export const CPQ_GUARDED_WRITE_ERRORS: OpenApiResponseDoc[] = [
|
|
15
|
+
cpqErrorDoc(400, 'Validation error'),
|
|
16
|
+
cpqErrorDoc(404, 'Quote or line not found'),
|
|
17
|
+
cpqErrorDoc(409, 'Quote is in a terminal status'),
|
|
18
|
+
cpqErrorDoc(422, 'Blocked by a mutation guard'),
|
|
19
|
+
]
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
+
import { cpqErrorDoc } from '../../_helpers/quoteRouteOpenApi'
|
|
2
5
|
import { resolveCpqRouteContext } from '../../context'
|
|
3
6
|
import { isOrderError } from '../../../services/cpqOrderService'
|
|
4
7
|
import type { DefaultCpqOrderService } from '../../../services/cpqOrderService'
|
|
@@ -26,3 +29,18 @@ export async function GET(req: Request, { params }: { params: Promise<{ orderId:
|
|
|
26
29
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
27
30
|
}
|
|
28
31
|
}
|
|
32
|
+
|
|
33
|
+
export const openApi: OpenApiRouteDoc = {
|
|
34
|
+
tag: 'CPQ Orders',
|
|
35
|
+
summary: 'CPQ order',
|
|
36
|
+
pathParams: z.object({ orderId: z.string().uuid() }),
|
|
37
|
+
methods: {
|
|
38
|
+
GET: {
|
|
39
|
+
summary: 'Get a CPQ order with its lines and pricing summary',
|
|
40
|
+
description:
|
|
41
|
+
'`pricingSummary` carries NRC / MRC totals plus the source quote\'s waterfall figures (`listNrcTotal`, `listMrcTotal`, `standard*Total`, `ruleDiscountTotal`, `manualDiscountTotal`, `dealValue`, `discountDepth`), computed from the line charges copied at conversion. ARC lines carry `arcSource` (the subscription item before the change).',
|
|
42
|
+
responses: [{ status: 200, description: 'The order' }],
|
|
43
|
+
errors: [cpqErrorDoc(401, 'Unauthorized'), cpqErrorDoc(404, 'Order not found')],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
@@ -4,6 +4,9 @@ import { resolveCpqRouteContext } from '../context'
|
|
|
4
4
|
import { cpqConvertQuoteToOrderSchema } from '../../data/validators'
|
|
5
5
|
import { isOrderError } from '../../services/cpqOrderService'
|
|
6
6
|
import type { DefaultCpqOrderService } from '../../services/cpqOrderService'
|
|
7
|
+
import { CPQ_QUOTE_RESOURCE_KIND, runCpqMutationGuards } from '../_helpers/quoteMutationGuard'
|
|
8
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
9
|
+
import { cpqErrorDoc } from '../_helpers/quoteRouteOpenApi'
|
|
7
10
|
|
|
8
11
|
export const metadata = {
|
|
9
12
|
POST: { requireAuth: true, requireFeatures: ['cpq.orders.manage'] },
|
|
@@ -18,8 +21,19 @@ export async function POST(req: Request) {
|
|
|
18
21
|
const body = cpqConvertQuoteToOrderSchema.parse(await req.json())
|
|
19
22
|
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
20
23
|
|
|
24
|
+
// Conversion turns a quote into an order — projects can block it (e.g.
|
|
25
|
+
// until an approval workflow has signed a discounted quote off).
|
|
26
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
27
|
+
resourceKind: CPQ_QUOTE_RESOURCE_KIND,
|
|
28
|
+
resourceId: body.quoteId,
|
|
29
|
+
operation: 'custom',
|
|
30
|
+
mutationPayload: { action: 'convert_to_order' },
|
|
31
|
+
})
|
|
32
|
+
if (!guard.ok) return guard.response
|
|
33
|
+
|
|
21
34
|
const service = ctx.container.resolve('cpqOrderService') as DefaultCpqOrderService
|
|
22
35
|
const result = await service.convertQuoteToOrder(body.quoteId, scope)
|
|
36
|
+
await guard.runAfterSuccess()
|
|
23
37
|
return NextResponse.json(result, { status: 201 })
|
|
24
38
|
} catch (err) {
|
|
25
39
|
return handleError(err, 'cpq/orders.POST')
|
|
@@ -66,7 +80,7 @@ function handleError(err: unknown, context: string) {
|
|
|
66
80
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
67
81
|
}
|
|
68
82
|
if (isOrderError(err)) {
|
|
69
|
-
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
83
|
+
return NextResponse.json({ error: err.message, ...err.details }, { status: err.status })
|
|
70
84
|
}
|
|
71
85
|
const errObj = err as Record<string, unknown>
|
|
72
86
|
if (typeof errObj?.status === 'number' && typeof errObj?.error === 'string') {
|
|
@@ -75,3 +89,39 @@ function handleError(err: unknown, context: string) {
|
|
|
75
89
|
console.error(`[${context}]`, err)
|
|
76
90
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
77
91
|
}
|
|
92
|
+
|
|
93
|
+
export const openApi: OpenApiRouteDoc = {
|
|
94
|
+
tag: 'CPQ Orders',
|
|
95
|
+
summary: 'CPQ orders',
|
|
96
|
+
methods: {
|
|
97
|
+
POST: {
|
|
98
|
+
summary: 'Convert a CPQ quote into an order',
|
|
99
|
+
description:
|
|
100
|
+
'Creates the sales order and CPQ order configuration from the quote. Refused for cancelled or rejected quotes; mutation guards (resource kind `cpq.quote`, action `convert_to_order`) run first.',
|
|
101
|
+
requestBody: { schema: cpqConvertQuoteToOrderSchema },
|
|
102
|
+
responses: [{ status: 201, description: 'Created order' }],
|
|
103
|
+
errors: [
|
|
104
|
+
cpqErrorDoc(400, 'Validation error'),
|
|
105
|
+
cpqErrorDoc(404, 'Quote not found'),
|
|
106
|
+
cpqErrorDoc(409, 'Quote cannot be converted in its status or is already converted'),
|
|
107
|
+
cpqErrorDoc(422, 'Quote has no lines or has invalid stored adjustments, or blocked by a mutation guard'),
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
GET: {
|
|
111
|
+
summary: 'List CPQ orders',
|
|
112
|
+
query: z.object({
|
|
113
|
+
customerId: z.string().uuid().optional(),
|
|
114
|
+
sourceQuoteId: z.string().uuid().optional(),
|
|
115
|
+
salesOrderId: z.string().uuid().optional(),
|
|
116
|
+
cpqStatus: z.string().optional(),
|
|
117
|
+
currencyCode: z.string().optional(),
|
|
118
|
+
search: z.string().optional(),
|
|
119
|
+
sortField: z.enum(['createdAt', 'updatedAt', 'cpqStatus', 'activatedAt', 'currencyCode']).optional(),
|
|
120
|
+
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
121
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
122
|
+
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
|
123
|
+
}),
|
|
124
|
+
responses: [{ status: 200, description: 'Paged list of CPQ orders' }],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { resolveCpqRouteContext } from '../../../../../context'
|
|
4
|
+
import { isQuotingError } from '../../../../../../services/cpqQuotingService'
|
|
5
|
+
import { resolveQuotingService } from '../../../../../resolveQuotingService'
|
|
6
|
+
import { CPQ_QUOTE_LINE_RESOURCE_KIND, runCpqMutationGuards } from '../../../../../_helpers/quoteMutationGuard'
|
|
7
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
8
|
+
import { CPQ_GUARDED_WRITE_ERRORS } from '../../../../../_helpers/quoteRouteOpenApi'
|
|
9
|
+
|
|
10
|
+
export const metadata = {
|
|
11
|
+
POST: { requireAuth: true, requireFeatures: ['cpq.quotes.manage'] },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function POST(req: Request, { params }: { params: Promise<{ quoteId: string; lineId: string }> }) {
|
|
15
|
+
try {
|
|
16
|
+
const ctx = await resolveCpqRouteContext(req)
|
|
17
|
+
if (!ctx.auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
18
|
+
|
|
19
|
+
const { quoteId, lineId } = await params
|
|
20
|
+
const scope = { organizationId: ctx.organizationId, tenantId: ctx.tenantId }
|
|
21
|
+
|
|
22
|
+
const guard = await runCpqMutationGuards(ctx, req, {
|
|
23
|
+
resourceKind: CPQ_QUOTE_LINE_RESOURCE_KIND,
|
|
24
|
+
resourceId: lineId,
|
|
25
|
+
operation: 'update',
|
|
26
|
+
mutationPayload: { quoteId, restore: true },
|
|
27
|
+
})
|
|
28
|
+
if (!guard.ok) return guard.response
|
|
29
|
+
|
|
30
|
+
const service = resolveQuotingService(ctx)
|
|
31
|
+
const result = await service.restoreQuoteItem(quoteId, lineId, scope)
|
|
32
|
+
await guard.runAfterSuccess()
|
|
33
|
+
return NextResponse.json(result)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (isQuotingError(err)) {
|
|
36
|
+
return NextResponse.json({ error: err.message }, { status: err.status })
|
|
37
|
+
}
|
|
38
|
+
console.error('[cpq/quotes/[quoteId]/items/[lineId]/restore.POST]', err)
|
|
39
|
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const openApi: OpenApiRouteDoc = {
|
|
44
|
+
tag: 'CPQ Quotes',
|
|
45
|
+
summary: 'Restore a subscription item marked for removal',
|
|
46
|
+
pathParams: z.object({ quoteId: z.string().uuid(), lineId: z.string().uuid() }),
|
|
47
|
+
methods: {
|
|
48
|
+
POST: {
|
|
49
|
+
summary: 'Restore a removed subscription item on an Amend / Renew quote',
|
|
50
|
+
description:
|
|
51
|
+
'Turns a `cancel` line that mirrors a live subscription item back into a `modify` line, rebuilt from the item (configuration, quantity, stored price). Its cancelled mirrored components are restored with it. 409 when the line is not a removed item, its parent is still removed, or the item is no longer active. Mutation guards run first (resource kind `cpq.quote_line`, update).',
|
|
52
|
+
responses: [{ status: 200, description: 'Quote after the restore' }],
|
|
53
|
+
errors: CPQ_GUARDED_WRITE_ERRORS.filter((doc) => doc.status !== 400),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}
|