@dainamite/cpq 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +236 -0
- package/README.md +108 -0
- package/dist/modules/cpq/acl.js +30 -0
- package/dist/modules/cpq/acl.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/handleArcError.js +37 -0
- package/dist/modules/cpq/api/_helpers/handleArcError.js.map +7 -0
- package/dist/modules/cpq/api/attribute-options/route.js +47 -0
- package/dist/modules/cpq/api/attribute-options/route.js.map +7 -0
- package/dist/modules/cpq/api/bundle-slots/route.js +101 -0
- package/dist/modules/cpq/api/bundle-slots/route.js.map +7 -0
- package/dist/modules/cpq/api/bundle-tree/route.js +28 -0
- package/dist/modules/cpq/api/bundle-tree/route.js.map +7 -0
- package/dist/modules/cpq/api/context.js +29 -0
- package/dist/modules/cpq/api/context.js.map +7 -0
- package/dist/modules/cpq/api/inventory/assets/route.js +113 -0
- package/dist/modules/cpq/api/inventory/assets/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/assets/status/route.js +35 -0
- package/dist/modules/cpq/api/inventory/assets/status/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.js +30 -0
- package/dist/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/customer/[customerId]/route.js +31 -0
- package/dist/modules/cpq/api/inventory/customer/[customerId]/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/subscription-items/route.js +99 -0
- package/dist/modules/cpq/api/inventory/subscription-items/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.js +57 -0
- package/dist/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/subscriptions/expiring/route.js +62 -0
- package/dist/modules/cpq/api/inventory/subscriptions/expiring/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/subscriptions/route.js +116 -0
- package/dist/modules/cpq/api/inventory/subscriptions/route.js.map +7 -0
- package/dist/modules/cpq/api/inventory/subscriptions/status/route.js +35 -0
- package/dist/modules/cpq/api/inventory/subscriptions/status/route.js.map +7 -0
- package/dist/modules/cpq/api/offering-components/route.js +91 -0
- package/dist/modules/cpq/api/offering-components/route.js.map +7 -0
- package/dist/modules/cpq/api/orders/[orderId]/activate/route.js +39 -0
- package/dist/modules/cpq/api/orders/[orderId]/activate/route.js.map +7 -0
- package/dist/modules/cpq/api/orders/[orderId]/route.js +28 -0
- package/dist/modules/cpq/api/orders/[orderId]/route.js.map +7 -0
- package/dist/modules/cpq/api/orders/[orderId]/status/route.js +38 -0
- package/dist/modules/cpq/api/orders/[orderId]/status/route.js.map +7 -0
- package/dist/modules/cpq/api/orders/route.js +71 -0
- package/dist/modules/cpq/api/orders/route.js.map +7 -0
- package/dist/modules/cpq/api/price-rules/route.js +180 -0
- package/dist/modules/cpq/api/price-rules/route.js.map +7 -0
- package/dist/modules/cpq/api/pricing-table-entries/route.js +142 -0
- package/dist/modules/cpq/api/pricing-table-entries/route.js.map +7 -0
- package/dist/modules/cpq/api/pricing-tables/route.js +179 -0
- package/dist/modules/cpq/api/pricing-tables/route.js.map +7 -0
- package/dist/modules/cpq/api/product-attributes/route.js +160 -0
- package/dist/modules/cpq/api/product-attributes/route.js.map +7 -0
- package/dist/modules/cpq/api/product-charges/route.js +166 -0
- package/dist/modules/cpq/api/product-charges/route.js.map +7 -0
- package/dist/modules/cpq/api/product-offerings/route.js +294 -0
- package/dist/modules/cpq/api/product-offerings/route.js.map +7 -0
- package/dist/modules/cpq/api/product-relationships/route.js +144 -0
- package/dist/modules/cpq/api/product-relationships/route.js.map +7 -0
- package/dist/modules/cpq/api/product-specifications/route.js +292 -0
- package/dist/modules/cpq/api/product-specifications/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.js +71 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js +29 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/clone/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +54 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/merge-meta/route.js +72 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/merge-meta/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +35 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/route.js +102 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +27 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.js +79 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.js +95 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/attributes/route.js +52 -0
- package/dist/modules/cpq/api/quotes/attributes/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/from-subscription/route.js +72 -0
- package/dist/modules/cpq/api/quotes/from-subscription/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/price/route.js +69 -0
- package/dist/modules/cpq/api/quotes/price/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/route.js +97 -0
- package/dist/modules/cpq/api/quotes/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/validate-relationships/route.js +86 -0
- package/dist/modules/cpq/api/quotes/validate-relationships/route.js.map +7 -0
- package/dist/modules/cpq/api/resolveQuotingService.js +7 -0
- package/dist/modules/cpq/api/resolveQuotingService.js.map +7 -0
- package/dist/modules/cpq/api/wizards/resolve/route.js +38 -0
- package/dist/modules/cpq/api/wizards/resolve/route.js.map +7 -0
- package/dist/modules/cpq/api/wizards/route.js +111 -0
- package/dist/modules/cpq/api/wizards/route.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +136 -0
- package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +185 -0
- package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +343 -0
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +13 -0
- package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/page.js +35 -0
- package/dist/modules/cpq/backend/cpq/inventory/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/shared.js +20 -0
- package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +794 -0
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +13 -0
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +917 -0
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/offerings/page.js +405 -0
- package/dist/modules/cpq/backend/cpq/offerings/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/offerings/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/offerings/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +677 -0
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/orders/page.js +134 -0
- package/dist/modules/cpq/backend/cpq/orders/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +272 -0
- package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +407 -0
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +13 -0
- package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/price-rules/page.js +211 -0
- package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/price-rules/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/price-rules/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +275 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +112 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +492 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.js +50 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +249 -0
- package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/page.js +87 -0
- package/dist/modules/cpq/backend/cpq/pricing/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/pricing/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/pricing/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +652 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +1289 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/page.js +283 -0
- package/dist/modules/cpq/backend/cpq/quotes/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +214 -0
- package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +1399 -0
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/specifications/page.js +148 -0
- package/dist/modules/cpq/backend/cpq/specifications/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/specifications/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/specifications/page.meta.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +204 -0
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +191 -0
- package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/wizards/page.js +119 -0
- package/dist/modules/cpq/backend/cpq/wizards/page.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/wizards/page.meta.js +18 -0
- package/dist/modules/cpq/backend/cpq/wizards/page.meta.js.map +7 -0
- package/dist/modules/cpq/ce.js +151 -0
- package/dist/modules/cpq/ce.js.map +7 -0
- package/dist/modules/cpq/cli.js +11 -0
- package/dist/modules/cpq/cli.js.map +7 -0
- package/dist/modules/cpq/components/CpqListView.js +265 -0
- package/dist/modules/cpq/components/CpqListView.js.map +7 -0
- package/dist/modules/cpq/components/NumberInput.js +95 -0
- package/dist/modules/cpq/components/NumberInput.js.map +7 -0
- package/dist/modules/cpq/components/StatusPath.js +60 -0
- package/dist/modules/cpq/components/StatusPath.js.map +7 -0
- package/dist/modules/cpq/components/statusMaps.js +66 -0
- package/dist/modules/cpq/components/statusMaps.js.map +7 -0
- package/dist/modules/cpq/components/useCpqRowActions.js +125 -0
- package/dist/modules/cpq/components/useCpqRowActions.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +1556 -0
- package/dist/modules/cpq/data/entities.js.map +7 -0
- package/dist/modules/cpq/data/validators.js +575 -0
- package/dist/modules/cpq/data/validators.js.map +7 -0
- package/dist/modules/cpq/di.js +57 -0
- package/dist/modules/cpq/di.js.map +7 -0
- package/dist/modules/cpq/events.js +50 -0
- package/dist/modules/cpq/events.js.map +7 -0
- package/dist/modules/cpq/index.js +16 -0
- package/dist/modules/cpq/index.js.map +7 -0
- package/dist/modules/cpq/lib/seeds/api.js +32 -0
- package/dist/modules/cpq/lib/seeds/api.js.map +7 -0
- package/dist/modules/cpq/lib/seeds/tenant-provisioning.js +158 -0
- package/dist/modules/cpq/lib/seeds/tenant-provisioning.js.map +7 -0
- package/dist/modules/cpq/lib/seeds.js +6 -0
- package/dist/modules/cpq/lib/seeds.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260220220000_cpq.js +40 -0
- package/dist/modules/cpq/migrations/Migration20260220220000_cpq.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.js +54 -0
- package/dist/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.js +15 -0
- package/dist/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.js +62 -0
- package/dist/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260402000000_cpq_workflows.js +56 -0
- package/dist/modules/cpq/migrations/Migration20260402000000_cpq_workflows.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.js +26 -0
- package/dist/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.js +37 -0
- package/dist/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.js +35 -0
- package/dist/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.js +21 -0
- package/dist/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.js +46 -0
- package/dist/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260410100000_cpq_inventory.js +118 -0
- package/dist/modules/cpq/migrations/Migration20260410100000_cpq_inventory.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260411000000_cpq_orders.js +63 -0
- package/dist/modules/cpq/migrations/Migration20260411000000_cpq_orders.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.js +28 -0
- package/dist/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260504064621_cpq_arc.js +36 -0
- package/dist/modules/cpq/migrations/Migration20260504064621_cpq_arc.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.js +14 -0
- package/dist/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.js +14 -0
- package/dist/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.js +58 -0
- package/dist/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.js +29 -0
- package/dist/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.js.map +7 -0
- package/dist/modules/cpq/services/cpqBundleService.js +447 -0
- package/dist/modules/cpq/services/cpqBundleService.js.map +7 -0
- package/dist/modules/cpq/services/cpqInventoryService.js +1371 -0
- package/dist/modules/cpq/services/cpqInventoryService.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +901 -0
- package/dist/modules/cpq/services/cpqOrderService.js.map +7 -0
- package/dist/modules/cpq/services/cpqPricingService.js +289 -0
- package/dist/modules/cpq/services/cpqPricingService.js.map +7 -0
- package/dist/modules/cpq/services/cpqProductService.js +204 -0
- package/dist/modules/cpq/services/cpqProductService.js.map +7 -0
- package/dist/modules/cpq/services/cpqQuotingService.js +1867 -0
- package/dist/modules/cpq/services/cpqQuotingService.js.map +7 -0
- package/dist/modules/cpq/services/cpqValidationService.js +234 -0
- package/dist/modules/cpq/services/cpqValidationService.js.map +7 -0
- package/dist/modules/cpq/services/cpqWizardService.js +137 -0
- package/dist/modules/cpq/services/cpqWizardService.js.map +7 -0
- package/dist/modules/cpq/services/types.js +114 -0
- package/dist/modules/cpq/services/types.js.map +7 -0
- package/dist/modules/cpq/setup.js +49 -0
- package/dist/modules/cpq/setup.js.map +7 -0
- package/dist/modules/cpq/subscribers/subscription-amended-log.js +18 -0
- package/dist/modules/cpq/subscribers/subscription-amended-log.js.map +7 -0
- package/dist/modules/cpq/subscribers/subscription-cancelled-log.js +19 -0
- package/dist/modules/cpq/subscribers/subscription-cancelled-log.js.map +7 -0
- package/dist/modules/cpq/subscribers/subscription-merged-log.js +20 -0
- package/dist/modules/cpq/subscribers/subscription-merged-log.js.map +7 -0
- package/dist/modules/cpq/subscribers/subscription-renewed-log.js +18 -0
- package/dist/modules/cpq/subscribers/subscription-renewed-log.js.map +7 -0
- package/dist/modules/cpq/subscribers/subscription-superseded-log.js +18 -0
- package/dist/modules/cpq/subscribers/subscription-superseded-log.js.map +7 -0
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +192 -0
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +7 -0
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.js +17 -0
- package/dist/modules/cpq/widgets/injection/customer-inventory/widget.js.map +7 -0
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +79 -0
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +7 -0
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.js +17 -0
- package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.js.map +7 -0
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +181 -0
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +7 -0
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.js +17 -0
- package/dist/modules/cpq/widgets/injection/quote-configurator/widget.js.map +7 -0
- package/dist/modules/cpq/widgets/injection-table.js +44 -0
- package/dist/modules/cpq/widgets/injection-table.js.map +7 -0
- package/dist/modules/cpq/workflows/WizardRunner.js +113 -0
- package/dist/modules/cpq/workflows/WizardRunner.js.map +7 -0
- package/dist/modules/cpq/workflows/WizardTrigger.js +116 -0
- package/dist/modules/cpq/workflows/WizardTrigger.js.map +7 -0
- package/dist/modules/cpq/workflows/engine.js +188 -0
- package/dist/modules/cpq/workflows/engine.js.map +7 -0
- package/dist/modules/cpq/workflows/registry.js +16 -0
- package/dist/modules/cpq/workflows/registry.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +119 -0
- package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +24 -0
- package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/ItemListStep.js +115 -0
- package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +327 -0
- package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +231 -0
- package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/ReviewStep.js +152 -0
- package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +7 -0
- package/dist/modules/cpq/workflows/steps/index.js +49 -0
- package/dist/modules/cpq/workflows/steps/index.js.map +7 -0
- package/dist/modules/cpq/workflows/types.js +1 -0
- package/dist/modules/cpq/workflows/types.js.map +7 -0
- package/package.json +150 -0
- package/src/modules/cpq/__integration__/TC-CPQ-001.spec.ts +177 -0
- package/src/modules/cpq/__integration__/TC-CPQ-002.spec.ts +123 -0
- package/src/modules/cpq/__integration__/TC-CPQ-003.spec.ts +115 -0
- package/src/modules/cpq/__integration__/TC-CPQ-004.spec.ts +184 -0
- package/src/modules/cpq/__integration__/TC-CPQ-005.spec.ts +43 -0
- package/src/modules/cpq/__integration__/TC-CPQ-006.spec.ts +87 -0
- package/src/modules/cpq/__integration__/TC-CPQ-007.spec.ts +79 -0
- package/src/modules/cpq/__integration__/TC-CPQ-008.spec.ts +55 -0
- package/src/modules/cpq/__integration__/TC-CPQ-009.spec.ts +45 -0
- package/src/modules/cpq/__integration__/TC-CPQ-010.spec.ts +54 -0
- package/src/modules/cpq/__integration__/TC-CPQ-011.spec.ts +24 -0
- package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +62 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-001.spec.ts +52 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-002.spec.ts +65 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-003.spec.ts +107 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-004.spec.ts +74 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-005.spec.ts +86 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-006.spec.ts +79 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-007.spec.ts +63 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-008.spec.ts +62 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-009.spec.ts +62 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-010.spec.ts +59 -0
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-011.spec.ts +128 -0
- package/src/modules/cpq/__integration__/_helpers/cpqArcFixtures.ts +208 -0
- package/src/modules/cpq/__tests__/package-isolation.test.ts +232 -0
- package/src/modules/cpq/acl.ts +26 -0
- package/src/modules/cpq/api/_helpers/__tests__/handleArcError.test.ts +117 -0
- package/src/modules/cpq/api/_helpers/handleArcError.ts +81 -0
- package/src/modules/cpq/api/attribute-options/route.ts +55 -0
- package/src/modules/cpq/api/bundle-slots/route.ts +112 -0
- package/src/modules/cpq/api/bundle-tree/route.ts +31 -0
- package/src/modules/cpq/api/context.ts +39 -0
- package/src/modules/cpq/api/inventory/assets/route.ts +123 -0
- package/src/modules/cpq/api/inventory/assets/status/route.ts +34 -0
- package/src/modules/cpq/api/inventory/customer/[customerId]/parent-items/route.ts +30 -0
- package/src/modules/cpq/api/inventory/customer/[customerId]/route.ts +31 -0
- package/src/modules/cpq/api/inventory/subscription-items/route.ts +107 -0
- package/src/modules/cpq/api/inventory/subscriptions/[subscriptionId]/change-log/route.ts +64 -0
- package/src/modules/cpq/api/inventory/subscriptions/expiring/route.ts +66 -0
- package/src/modules/cpq/api/inventory/subscriptions/route.ts +126 -0
- package/src/modules/cpq/api/inventory/subscriptions/status/route.ts +34 -0
- package/src/modules/cpq/api/offering-components/route.ts +103 -0
- package/src/modules/cpq/api/orders/[orderId]/activate/route.ts +48 -0
- package/src/modules/cpq/api/orders/[orderId]/route.ts +28 -0
- package/src/modules/cpq/api/orders/[orderId]/status/route.ts +38 -0
- package/src/modules/cpq/api/orders/route.ts +77 -0
- package/src/modules/cpq/api/price-rules/route.ts +210 -0
- package/src/modules/cpq/api/pricing-table-entries/route.ts +159 -0
- package/src/modules/cpq/api/pricing-tables/route.ts +202 -0
- package/src/modules/cpq/api/product-attributes/route.ts +175 -0
- package/src/modules/cpq/api/product-charges/route.ts +181 -0
- package/src/modules/cpq/api/product-offerings/route.ts +329 -0
- package/src/modules/cpq/api/product-relationships/route.ts +156 -0
- package/src/modules/cpq/api/product-specifications/route.ts +325 -0
- package/src/modules/cpq/api/quotes/[quoteId]/cancel-meta/route.ts +77 -0
- package/src/modules/cpq/api/quotes/[quoteId]/clone/route.ts +28 -0
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +55 -0
- package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +26 -0
- package/src/modules/cpq/api/quotes/[quoteId]/merge-meta/route.ts +78 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +34 -0
- package/src/modules/cpq/api/quotes/[quoteId]/route.ts +109 -0
- package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +26 -0
- package/src/modules/cpq/api/quotes/[quoteId]/target-subscriptions/[targetId]/route.ts +91 -0
- package/src/modules/cpq/api/quotes/[quoteId]/target-subscriptions/route.ts +107 -0
- package/src/modules/cpq/api/quotes/attributes/route.ts +61 -0
- package/src/modules/cpq/api/quotes/from-subscription/route.ts +85 -0
- package/src/modules/cpq/api/quotes/price/route.ts +81 -0
- package/src/modules/cpq/api/quotes/route.ts +114 -0
- package/src/modules/cpq/api/quotes/validate-relationships/route.ts +100 -0
- package/src/modules/cpq/api/resolveQuotingService.ts +11 -0
- package/src/modules/cpq/api/wizards/resolve/route.ts +39 -0
- package/src/modules/cpq/api/wizards/route.ts +127 -0
- package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +166 -0
- package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +251 -0
- package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +9 -0
- package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +440 -0
- package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/inventory/page.tsx +49 -0
- package/src/modules/cpq/backend/cpq/inventory/shared.tsx +32 -0
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +9 -0
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +1085 -0
- package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +1165 -0
- package/src/modules/cpq/backend/cpq/offerings/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/offerings/page.tsx +468 -0
- package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +921 -0
- package/src/modules/cpq/backend/cpq/orders/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/orders/page.tsx +182 -0
- package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +310 -0
- package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +9 -0
- package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +472 -0
- package/src/modules/cpq/backend/cpq/price-rules/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/price-rules/page.tsx +253 -0
- package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +431 -0
- package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +122 -0
- package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +588 -0
- package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxExport.ts +90 -0
- package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +338 -0
- package/src/modules/cpq/backend/cpq/pricing/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/pricing/page.tsx +111 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +737 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +1721 -0
- package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/quotes/page.tsx +370 -0
- package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +300 -0
- package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +1722 -0
- package/src/modules/cpq/backend/cpq/specifications/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/specifications/page.tsx +191 -0
- package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +319 -0
- package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +234 -0
- package/src/modules/cpq/backend/cpq/wizards/page.meta.ts +16 -0
- package/src/modules/cpq/backend/cpq/wizards/page.tsx +141 -0
- package/src/modules/cpq/ce.ts +150 -0
- package/src/modules/cpq/cli.ts +10 -0
- package/src/modules/cpq/components/CpqListView.tsx +431 -0
- package/src/modules/cpq/components/NumberInput.tsx +166 -0
- package/src/modules/cpq/components/StatusPath.tsx +116 -0
- package/src/modules/cpq/components/statusMaps.ts +148 -0
- package/src/modules/cpq/components/useCpqRowActions.tsx +183 -0
- package/src/modules/cpq/data/__tests__/validators.arc.test.ts +271 -0
- package/src/modules/cpq/data/__tests__/validators.test.ts +782 -0
- package/src/modules/cpq/data/entities.ts +1467 -0
- package/src/modules/cpq/data/validators.ts +663 -0
- package/src/modules/cpq/di.ts +70 -0
- package/src/modules/cpq/events.ts +62 -0
- package/src/modules/cpq/index.ts +15 -0
- package/src/modules/cpq/lib/seeds/__tests__/api.test.ts +86 -0
- package/src/modules/cpq/lib/seeds/__tests__/tenant-provisioning.test.ts +349 -0
- package/src/modules/cpq/lib/seeds/api.ts +119 -0
- package/src/modules/cpq/lib/seeds/tenant-provisioning.ts +239 -0
- package/src/modules/cpq/lib/seeds.ts +18 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +7473 -0
- package/src/modules/cpq/migrations/Migration20260220220000_cpq.ts +51 -0
- package/src/modules/cpq/migrations/Migration20260330000000_cpq_product_domain.ts +70 -0
- package/src/modules/cpq/migrations/Migration20260331000000_cpq_charge_unique_per_offering.ts +15 -0
- package/src/modules/cpq/migrations/Migration20260401000000_cpq_quoting_v2.ts +73 -0
- package/src/modules/cpq/migrations/Migration20260402000000_cpq_workflows.ts +59 -0
- package/src/modules/cpq/migrations/Migration20260407000000_cpq_pricing_tables_v2.ts +30 -0
- package/src/modules/cpq/migrations/Migration20260407100000_cpq_price_rules.ts +36 -0
- package/src/modules/cpq/migrations/Migration20260408000000_cpq_price_rules.ts +33 -0
- package/src/modules/cpq/migrations/Migration20260410000000_cpq_drop_datacentres_inventory.ts +22 -0
- package/src/modules/cpq/migrations/Migration20260410000000_cpq_wizard_rename.ts +56 -0
- package/src/modules/cpq/migrations/Migration20260410100000_cpq_inventory.ts +118 -0
- package/src/modules/cpq/migrations/Migration20260411000000_cpq_orders.ts +62 -0
- package/src/modules/cpq/migrations/Migration20260412000000_cpq_bundled_offerings.ts +33 -0
- package/src/modules/cpq/migrations/Migration20260504064621_cpq_arc.ts +68 -0
- package/src/modules/cpq/migrations/Migration20260514000000_cpq_spec_merge_retired_deprecated.ts +30 -0
- package/src/modules/cpq/migrations/Migration20260514100000_cpq_offering_merge_retired_deprecated.ts +27 -0
- package/src/modules/cpq/migrations/Migration20260514110000_cpq_list_search_indexes.ts +82 -0
- package/src/modules/cpq/migrations/Migration20260515000000_cpq_inventory_subscriptions_search_indexes.ts +52 -0
- package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +59 -0
- package/src/modules/cpq/services/__tests__/cpqBundleService.test.ts +661 -0
- package/src/modules/cpq/services/__tests__/cpqInventoryService.arc.test.ts +357 -0
- package/src/modules/cpq/services/__tests__/cpqInventoryService.test.ts +718 -0
- package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +603 -0
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +1012 -0
- package/src/modules/cpq/services/__tests__/cpqProductService.test.ts +603 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +143 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +658 -0
- package/src/modules/cpq/services/__tests__/cpqValidationService.test.ts +811 -0
- package/src/modules/cpq/services/__tests__/cpqWizardService.test.ts +389 -0
- package/src/modules/cpq/services/__tests__/types.test.ts +359 -0
- package/src/modules/cpq/services/cpqBundleService.ts +671 -0
- package/src/modules/cpq/services/cpqInventoryService.ts +1929 -0
- package/src/modules/cpq/services/cpqOrderService.ts +1230 -0
- package/src/modules/cpq/services/cpqPricingService.ts +412 -0
- package/src/modules/cpq/services/cpqProductService.ts +311 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +2638 -0
- package/src/modules/cpq/services/cpqValidationService.ts +323 -0
- package/src/modules/cpq/services/cpqWizardService.ts +204 -0
- package/src/modules/cpq/services/types.ts +303 -0
- package/src/modules/cpq/setup.ts +63 -0
- package/src/modules/cpq/subscribers/subscription-amended-log.ts +20 -0
- package/src/modules/cpq/subscribers/subscription-cancelled-log.ts +19 -0
- package/src/modules/cpq/subscribers/subscription-merged-log.ts +22 -0
- package/src/modules/cpq/subscribers/subscription-renewed-log.ts +18 -0
- package/src/modules/cpq/subscribers/subscription-superseded-log.ts +19 -0
- package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +339 -0
- package/src/modules/cpq/widgets/injection/customer-inventory/widget.ts +16 -0
- package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +96 -0
- package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.ts +16 -0
- package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +265 -0
- package/src/modules/cpq/widgets/injection/quote-configurator/widget.ts +16 -0
- package/src/modules/cpq/widgets/injection-table.ts +42 -0
- package/src/modules/cpq/workflows/WizardRunner.tsx +158 -0
- package/src/modules/cpq/workflows/WizardTrigger.tsx +142 -0
- package/src/modules/cpq/workflows/engine.tsx +278 -0
- package/src/modules/cpq/workflows/registry.ts +15 -0
- package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +136 -0
- package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +34 -0
- package/src/modules/cpq/workflows/steps/ItemListStep.tsx +146 -0
- package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +438 -0
- package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +287 -0
- package/src/modules/cpq/workflows/steps/ReviewStep.tsx +219 -0
- package/src/modules/cpq/workflows/steps/index.ts +51 -0
- package/src/modules/cpq/workflows/types.ts +102 -0
|
@@ -0,0 +1,1929 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import {
|
|
3
|
+
CpqInventorySubscription,
|
|
4
|
+
CpqInventorySubscriptionItem,
|
|
5
|
+
CpqInventoryAsset,
|
|
6
|
+
CpqProductSpecification,
|
|
7
|
+
CpqSubscriptionChangeLog,
|
|
8
|
+
} from '../data/entities'
|
|
9
|
+
import {
|
|
10
|
+
INVENTORY_SUBSCRIPTION_TRANSITIONS,
|
|
11
|
+
INVENTORY_ASSET_TRANSITIONS,
|
|
12
|
+
getBaseCurrencyCode,
|
|
13
|
+
type ChangeLogType,
|
|
14
|
+
type InventorySubscriptionStatus,
|
|
15
|
+
type InventoryAssetStatus,
|
|
16
|
+
type TenantScope,
|
|
17
|
+
} from './types'
|
|
18
|
+
|
|
19
|
+
// ─── Filter / Input / Result types ───────────────────────────────
|
|
20
|
+
|
|
21
|
+
interface SubscriptionListFilters {
|
|
22
|
+
customerId?: string
|
|
23
|
+
sourceOrderId?: string
|
|
24
|
+
status?: string | string[]
|
|
25
|
+
productId?: string
|
|
26
|
+
billingCycle?: string
|
|
27
|
+
search?: string
|
|
28
|
+
/** Only subscriptions whose currentTermEnd falls within the next N days. */
|
|
29
|
+
expiringWithinDays?: number
|
|
30
|
+
sortField?: 'createdAt' | 'updatedAt' | 'code' | 'name' | 'status' | 'startDate' | 'currentTermEnd'
|
|
31
|
+
sortDir?: 'asc' | 'desc'
|
|
32
|
+
page?: number
|
|
33
|
+
pageSize?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SubscriptionItemListFilters {
|
|
37
|
+
subscriptionId?: string
|
|
38
|
+
parentItemId?: string
|
|
39
|
+
status?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AssetListFilters {
|
|
43
|
+
customerId?: string
|
|
44
|
+
status?: string | string[]
|
|
45
|
+
assetType?: string
|
|
46
|
+
productId?: string
|
|
47
|
+
subscriptionId?: string
|
|
48
|
+
subscriptionItemId?: string
|
|
49
|
+
search?: string
|
|
50
|
+
sortField?: 'createdAt' | 'updatedAt' | 'code' | 'name' | 'status' | 'assetType' | 'purchasePrice'
|
|
51
|
+
sortDir?: 'asc' | 'desc'
|
|
52
|
+
page?: number
|
|
53
|
+
pageSize?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface CustomerInventoryFilters {
|
|
57
|
+
status?: string | string[]
|
|
58
|
+
productId?: string
|
|
59
|
+
includeTerminated?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── ARC (Amend / Renew / Cancel) — XD-250 input shapes ─────────
|
|
63
|
+
|
|
64
|
+
export type ArcLineAction = 'add' | 'modify' | 'cancel'
|
|
65
|
+
|
|
66
|
+
export interface ArcLineChange {
|
|
67
|
+
/** Source quote line id (for traceability + sourceQuoteLineId on items). */
|
|
68
|
+
quoteLineId?: string | null
|
|
69
|
+
action: ArcLineAction
|
|
70
|
+
/** Required for action ∈ {cancel, modify}. */
|
|
71
|
+
sourceSubscriptionItemId?: string | null
|
|
72
|
+
/** Required for action='add'. Used to materialize the new item. */
|
|
73
|
+
itemInput?: Omit<CreateSubscriptionItemInput, 'name'> & { name: string }
|
|
74
|
+
/** XD-250 ARC: source quote line's `parentLineId`. When set, applyLineChanges
|
|
75
|
+
* resolves the resulting `parentItemId` on the new sub item — either from
|
|
76
|
+
* the parent's `sourceSubscriptionItemId` (if parent is a modify line) or
|
|
77
|
+
* from another `add` line's pre-generated id (if parent is also brand new
|
|
78
|
+
* in this apply). Without this, bundle children added through ARC are
|
|
79
|
+
* created as standalone sub items, not nested under the bundle. */
|
|
80
|
+
parentQuoteLineId?: string | null
|
|
81
|
+
/** Optional metadata for action='modify' — the configuration / pricing changes. */
|
|
82
|
+
modifyPatch?: {
|
|
83
|
+
configuration?: Record<string, unknown>
|
|
84
|
+
charges?: Array<Record<string, unknown>>
|
|
85
|
+
mrcAmount?: number
|
|
86
|
+
nrcAmount?: number
|
|
87
|
+
quantity?: number
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ArcTermInput {
|
|
92
|
+
newTermStart: Date | string
|
|
93
|
+
newTermEnd: Date | string
|
|
94
|
+
newTermMonths?: number | null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ArcMergeMeta {
|
|
98
|
+
newSubCode?: string | null
|
|
99
|
+
newSubName?: string | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ApplyAmendmentInput {
|
|
103
|
+
subscriptionId: string
|
|
104
|
+
lineChanges: ArcLineChange[]
|
|
105
|
+
sourceQuoteId: string
|
|
106
|
+
sourceOrderId: string
|
|
107
|
+
performedByUserId?: string | null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ApplyRenewalInput extends ApplyAmendmentInput {
|
|
111
|
+
term: ArcTermInput
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ApplyMergeRenewalInput {
|
|
115
|
+
sourceIds: string[]
|
|
116
|
+
term: ArcTermInput
|
|
117
|
+
mergeMeta: ArcMergeMeta
|
|
118
|
+
lineChanges: ArcLineChange[]
|
|
119
|
+
sourceQuoteId: string
|
|
120
|
+
sourceOrderId: string
|
|
121
|
+
performedByUserId?: string | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ApplyCancelInput {
|
|
125
|
+
subscriptionId: string
|
|
126
|
+
sourceQuoteId: string
|
|
127
|
+
sourceOrderId: string
|
|
128
|
+
performedByUserId?: string | null
|
|
129
|
+
etfAmount?: number | string | null
|
|
130
|
+
etfCurrency?: string | null
|
|
131
|
+
reasonCode?: string | null
|
|
132
|
+
reasonText?: string | null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ApplyArcResult {
|
|
136
|
+
changeLog: CpqSubscriptionChangeLog
|
|
137
|
+
/** Subscription as left after mutation (for amend / renew / cancel) or M (for merge-result). */
|
|
138
|
+
subscription: CpqInventorySubscription
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ApplyMergeRenewalResult {
|
|
142
|
+
/** The new merge sub M. */
|
|
143
|
+
mergedSubscription: CpqInventorySubscription
|
|
144
|
+
/** The merge-result ChangeLog row (on M). */
|
|
145
|
+
mergeResultChangeLog: CpqSubscriptionChangeLog
|
|
146
|
+
/** One per source — change_type='merge-source'. */
|
|
147
|
+
sourceChangeLogs: CpqSubscriptionChangeLog[]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface CreateSubscriptionInput {
|
|
151
|
+
customerId: string
|
|
152
|
+
code?: string
|
|
153
|
+
name: string
|
|
154
|
+
description?: string
|
|
155
|
+
billingCycle?: string
|
|
156
|
+
currencyCode?: string
|
|
157
|
+
startDate?: string
|
|
158
|
+
termMonths?: number
|
|
159
|
+
autoRenew?: boolean
|
|
160
|
+
sourceQuoteId?: string
|
|
161
|
+
sourceOrderId?: string
|
|
162
|
+
metadata?: Record<string, unknown>
|
|
163
|
+
items: CreateSubscriptionItemInput[]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface CreateSubscriptionItemInput {
|
|
167
|
+
parentItemId?: string
|
|
168
|
+
productId?: string
|
|
169
|
+
offeringId?: string
|
|
170
|
+
specId?: string
|
|
171
|
+
name: string
|
|
172
|
+
configuration?: Record<string, unknown>
|
|
173
|
+
charges?: Array<Record<string, unknown>>
|
|
174
|
+
mrcAmount?: number
|
|
175
|
+
nrcAmount?: number
|
|
176
|
+
currencyCode?: string
|
|
177
|
+
quantity?: number
|
|
178
|
+
capacityTotal?: number
|
|
179
|
+
capacityUnit?: string
|
|
180
|
+
sourceQuoteLineId?: string
|
|
181
|
+
metadata?: Record<string, unknown>
|
|
182
|
+
sortOrder?: number
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface UpdateSubscriptionInput {
|
|
186
|
+
name?: string
|
|
187
|
+
description?: string
|
|
188
|
+
billingCycle?: string
|
|
189
|
+
autoRenew?: boolean
|
|
190
|
+
termMonths?: number
|
|
191
|
+
currentTermEnd?: string
|
|
192
|
+
metadata?: Record<string, unknown>
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface UpdateSubscriptionItemInput {
|
|
196
|
+
name?: string
|
|
197
|
+
metadata?: Record<string, unknown>
|
|
198
|
+
capacityTotal?: number
|
|
199
|
+
capacityUsed?: number
|
|
200
|
+
capacityUnit?: string
|
|
201
|
+
sortOrder?: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface CreateAssetInput {
|
|
205
|
+
customerId: string
|
|
206
|
+
subscriptionId?: string
|
|
207
|
+
subscriptionItemId?: string
|
|
208
|
+
productId?: string
|
|
209
|
+
offeringId?: string
|
|
210
|
+
specId?: string
|
|
211
|
+
code?: string
|
|
212
|
+
name: string
|
|
213
|
+
description?: string
|
|
214
|
+
assetType?: string
|
|
215
|
+
currencyCode?: string
|
|
216
|
+
purchasePrice?: number
|
|
217
|
+
quantity?: number
|
|
218
|
+
purchaseDate?: string
|
|
219
|
+
deliveryDate?: string
|
|
220
|
+
configuration?: Record<string, unknown>
|
|
221
|
+
charges?: Array<Record<string, unknown>>
|
|
222
|
+
sourceQuoteId?: string
|
|
223
|
+
sourceOrderId?: string
|
|
224
|
+
sourceQuoteLineId?: string
|
|
225
|
+
metadata?: Record<string, unknown>
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface UpdateAssetInput {
|
|
229
|
+
name?: string
|
|
230
|
+
description?: string
|
|
231
|
+
assetType?: string
|
|
232
|
+
deliveryDate?: string
|
|
233
|
+
metadata?: Record<string, unknown>
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
interface PaginatedResult<T> {
|
|
237
|
+
items: T[]
|
|
238
|
+
total: number
|
|
239
|
+
page: number
|
|
240
|
+
pageSize: number
|
|
241
|
+
totalPages: number
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface SubscriptionItemResultNode {
|
|
245
|
+
id: string
|
|
246
|
+
subscriptionId: string
|
|
247
|
+
parentItemId: string | null
|
|
248
|
+
productId: string | null
|
|
249
|
+
offeringId: string | null
|
|
250
|
+
specId: string | null
|
|
251
|
+
name: string
|
|
252
|
+
status: string
|
|
253
|
+
configuration: Record<string, unknown>
|
|
254
|
+
charges: Array<Record<string, unknown>> | null
|
|
255
|
+
mrcAmount: number
|
|
256
|
+
nrcAmount: number
|
|
257
|
+
currencyCode: string
|
|
258
|
+
quantity: number
|
|
259
|
+
capacityTotal: number | null
|
|
260
|
+
capacityUsed: number | null
|
|
261
|
+
capacityUnit: string | null
|
|
262
|
+
metadata: Record<string, unknown> | null
|
|
263
|
+
sortOrder: number
|
|
264
|
+
children: SubscriptionItemResultNode[]
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Service implementation ──────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
export class DefaultCpqInventoryService {
|
|
270
|
+
private em: EntityManager
|
|
271
|
+
|
|
272
|
+
constructor(em: EntityManager) {
|
|
273
|
+
this.em = em
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ═══════════════════════════════════════════════════════════════
|
|
277
|
+
// Subscriptions
|
|
278
|
+
// ═══════════════════════════════════════════════════════════════
|
|
279
|
+
|
|
280
|
+
async listSubscriptions(filters: SubscriptionListFilters, scope: TenantScope): Promise<PaginatedResult<CpqInventorySubscription>> {
|
|
281
|
+
const page = Math.max(1, filters.page ?? 1)
|
|
282
|
+
const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
|
|
283
|
+
const offset = (page - 1) * pageSize
|
|
284
|
+
|
|
285
|
+
const where: Record<string, unknown> = {
|
|
286
|
+
organizationId: scope.organizationId,
|
|
287
|
+
tenantId: scope.tenantId,
|
|
288
|
+
deletedAt: null,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (filters.customerId) where.customerId = filters.customerId
|
|
292
|
+
if (filters.sourceOrderId) where.sourceOrderId = filters.sourceOrderId
|
|
293
|
+
if (filters.billingCycle) where.billingCycle = filters.billingCycle
|
|
294
|
+
if (filters.status) {
|
|
295
|
+
where.status = Array.isArray(filters.status)
|
|
296
|
+
? { $in: filters.status }
|
|
297
|
+
: filters.status
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const search = filters.search?.trim()
|
|
301
|
+
if (search) {
|
|
302
|
+
where.$or = [
|
|
303
|
+
{ code: { $ilike: `%${search}%` } },
|
|
304
|
+
{ name: { $ilike: `%${search}%` } },
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (filters.productId) {
|
|
309
|
+
const itemIds = await this.em.find(
|
|
310
|
+
CpqInventorySubscriptionItem,
|
|
311
|
+
{
|
|
312
|
+
organizationId: scope.organizationId,
|
|
313
|
+
tenantId: scope.tenantId,
|
|
314
|
+
deletedAt: null,
|
|
315
|
+
productId: filters.productId,
|
|
316
|
+
},
|
|
317
|
+
{ fields: ['subscriptionId'] as const },
|
|
318
|
+
)
|
|
319
|
+
const subIds = [...new Set(itemIds.map((i) => i.subscriptionId))]
|
|
320
|
+
if (subIds.length === 0) {
|
|
321
|
+
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
|
322
|
+
}
|
|
323
|
+
where.id = { $in: subIds }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Expiring window: subscriptions whose currentTermEnd is between now
|
|
327
|
+
// and now + N days. Replaces the separate /expiring page — operators
|
|
328
|
+
// save this as a perspective on the main list.
|
|
329
|
+
if (filters.expiringWithinDays != null && filters.expiringWithinDays > 0) {
|
|
330
|
+
const now = new Date()
|
|
331
|
+
const cutoff = new Date(now.getTime() + filters.expiringWithinDays * 24 * 60 * 60 * 1000)
|
|
332
|
+
where.currentTermEnd = { $gte: now, $lte: cutoff }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Default sort for the expiring view is "soonest first"; otherwise
|
|
336
|
+
// the caller's choice (or newest-first).
|
|
337
|
+
const defaultSortField =
|
|
338
|
+
filters.expiringWithinDays != null && filters.expiringWithinDays > 0
|
|
339
|
+
? 'currentTermEnd'
|
|
340
|
+
: 'createdAt'
|
|
341
|
+
const sortField = filters.sortField ?? defaultSortField
|
|
342
|
+
const sortDir = filters.sortDir === 'asc' ? 'asc' :
|
|
343
|
+
filters.sortField === undefined && filters.expiringWithinDays != null ? 'asc' : 'desc'
|
|
344
|
+
|
|
345
|
+
const [items, total] = await this.em.findAndCount(
|
|
346
|
+
CpqInventorySubscription,
|
|
347
|
+
where,
|
|
348
|
+
{ limit: pageSize, offset, orderBy: { [sortField]: sortDir } },
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
items,
|
|
353
|
+
total,
|
|
354
|
+
page,
|
|
355
|
+
pageSize,
|
|
356
|
+
totalPages: Math.ceil(total / pageSize),
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async getSubscription(subscriptionId: string, scope: TenantScope) {
|
|
361
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
362
|
+
id: subscriptionId,
|
|
363
|
+
organizationId: scope.organizationId,
|
|
364
|
+
tenantId: scope.tenantId,
|
|
365
|
+
deletedAt: null,
|
|
366
|
+
})
|
|
367
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
368
|
+
|
|
369
|
+
const allItems = await this.em.find(CpqInventorySubscriptionItem, {
|
|
370
|
+
subscriptionId: sub.id,
|
|
371
|
+
organizationId: scope.organizationId,
|
|
372
|
+
tenantId: scope.tenantId,
|
|
373
|
+
deletedAt: null,
|
|
374
|
+
}, { orderBy: { sortOrder: 'asc' } })
|
|
375
|
+
|
|
376
|
+
const itemTree = this.buildItemTree(allItems)
|
|
377
|
+
|
|
378
|
+
return { ...sub, items: itemTree }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async createSubscription(input: CreateSubscriptionInput, scope: TenantScope) {
|
|
382
|
+
const code = input.code ?? await this.generateSubscriptionCode(scope)
|
|
383
|
+
|
|
384
|
+
const subId = crypto.randomUUID()
|
|
385
|
+
const sub = this.em.create(CpqInventorySubscription, {
|
|
386
|
+
id: subId,
|
|
387
|
+
organizationId: scope.organizationId,
|
|
388
|
+
tenantId: scope.tenantId,
|
|
389
|
+
customerId: input.customerId,
|
|
390
|
+
code,
|
|
391
|
+
name: input.name,
|
|
392
|
+
description: input.description ?? null,
|
|
393
|
+
status: 'pending',
|
|
394
|
+
billingCycle: input.billingCycle ?? 'monthly',
|
|
395
|
+
currencyCode: input.currencyCode ?? await getBaseCurrencyCode(this.em, scope),
|
|
396
|
+
mrcAmount: '0',
|
|
397
|
+
nrcAmount: '0',
|
|
398
|
+
startDate: input.startDate ? new Date(input.startDate) : null,
|
|
399
|
+
termMonths: input.termMonths ?? null,
|
|
400
|
+
autoRenew: input.autoRenew ?? true,
|
|
401
|
+
sourceQuoteId: input.sourceQuoteId ?? null,
|
|
402
|
+
sourceOrderId: input.sourceOrderId ?? null,
|
|
403
|
+
metadata: input.metadata ?? null,
|
|
404
|
+
})
|
|
405
|
+
this.em.persist(sub)
|
|
406
|
+
|
|
407
|
+
const items: CpqInventorySubscriptionItem[] = []
|
|
408
|
+
for (const itemInput of input.items) {
|
|
409
|
+
const item = this.em.create(CpqInventorySubscriptionItem, {
|
|
410
|
+
organizationId: scope.organizationId,
|
|
411
|
+
tenantId: scope.tenantId,
|
|
412
|
+
subscriptionId: sub.id,
|
|
413
|
+
customerId: input.customerId,
|
|
414
|
+
parentItemId: itemInput.parentItemId ?? null,
|
|
415
|
+
productId: itemInput.productId ?? null,
|
|
416
|
+
offeringId: itemInput.offeringId ?? null,
|
|
417
|
+
specId: itemInput.specId ?? null,
|
|
418
|
+
name: itemInput.name,
|
|
419
|
+
status: 'pending',
|
|
420
|
+
configuration: itemInput.configuration ?? {},
|
|
421
|
+
charges: itemInput.charges ?? null,
|
|
422
|
+
mrcAmount: String(itemInput.mrcAmount ?? 0),
|
|
423
|
+
nrcAmount: String(itemInput.nrcAmount ?? 0),
|
|
424
|
+
currencyCode: itemInput.currencyCode ?? input.currencyCode ?? await getBaseCurrencyCode(this.em, scope),
|
|
425
|
+
quantity: itemInput.quantity ?? 1,
|
|
426
|
+
capacityTotal: itemInput.capacityTotal != null ? String(itemInput.capacityTotal) : null,
|
|
427
|
+
capacityUsed: null,
|
|
428
|
+
capacityUnit: itemInput.capacityUnit ?? null,
|
|
429
|
+
sourceQuoteLineId: itemInput.sourceQuoteLineId ?? null,
|
|
430
|
+
metadata: itemInput.metadata ?? null,
|
|
431
|
+
sortOrder: itemInput.sortOrder ?? 0,
|
|
432
|
+
})
|
|
433
|
+
this.em.persist(item)
|
|
434
|
+
items.push(item)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const mrcSum = items.reduce((s, i) => s + Number(i.mrcAmount), 0)
|
|
438
|
+
const nrcSum = items.reduce((s, i) => s + Number(i.nrcAmount), 0)
|
|
439
|
+
sub.mrcAmount = String(mrcSum)
|
|
440
|
+
sub.nrcAmount = String(nrcSum)
|
|
441
|
+
|
|
442
|
+
// Seed the change log with a `created` entry so the Change History
|
|
443
|
+
// panel always shows where the subscription came from — same shape
|
|
444
|
+
// as the `amend` / `renew` / `cancel` entries written by ARC flows.
|
|
445
|
+
// beforeSnapshot is null because there is no prior state; the after
|
|
446
|
+
// snapshot is the freshly-built subscription so the UI can diff
|
|
447
|
+
// against subsequent changes.
|
|
448
|
+
await this.em.flush() // need sub.id and items persisted before snapshot
|
|
449
|
+
const afterSnapshot = await this.snapshotSubscription(sub, scope)
|
|
450
|
+
const log = this.createChangeLog({
|
|
451
|
+
subscription: sub,
|
|
452
|
+
changeType: 'created',
|
|
453
|
+
sourceQuoteId: input.sourceQuoteId ?? null,
|
|
454
|
+
sourceOrderId: input.sourceOrderId ?? null,
|
|
455
|
+
performedByUserId: null,
|
|
456
|
+
beforeSnapshot: null,
|
|
457
|
+
afterSnapshot,
|
|
458
|
+
lineChanges: items.map((item) => ({
|
|
459
|
+
action: 'add' as const,
|
|
460
|
+
itemId: item.id,
|
|
461
|
+
sourceQuoteLineId: item.sourceQuoteLineId ?? null,
|
|
462
|
+
mrcAmount: Number(item.mrcAmount),
|
|
463
|
+
nrcAmount: Number(item.nrcAmount),
|
|
464
|
+
quantity: item.quantity,
|
|
465
|
+
})),
|
|
466
|
+
})
|
|
467
|
+
sub.lastChangeLogId = log.id
|
|
468
|
+
await this.em.flush()
|
|
469
|
+
return sub
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async updateSubscription(id: string, updates: UpdateSubscriptionInput, scope: TenantScope) {
|
|
473
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
474
|
+
id,
|
|
475
|
+
organizationId: scope.organizationId,
|
|
476
|
+
tenantId: scope.tenantId,
|
|
477
|
+
deletedAt: null,
|
|
478
|
+
})
|
|
479
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
480
|
+
|
|
481
|
+
if (updates.name !== undefined) sub.name = updates.name
|
|
482
|
+
if (updates.description !== undefined) sub.description = updates.description
|
|
483
|
+
if (updates.billingCycle !== undefined) sub.billingCycle = updates.billingCycle
|
|
484
|
+
if (updates.autoRenew !== undefined) sub.autoRenew = updates.autoRenew
|
|
485
|
+
if (updates.termMonths !== undefined) sub.termMonths = updates.termMonths
|
|
486
|
+
if (updates.currentTermEnd !== undefined) sub.currentTermEnd = updates.currentTermEnd ? new Date(updates.currentTermEnd) : null
|
|
487
|
+
if (updates.metadata !== undefined) sub.metadata = updates.metadata
|
|
488
|
+
|
|
489
|
+
await this.em.flush()
|
|
490
|
+
return sub
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async deleteSubscription(id: string, scope: TenantScope) {
|
|
494
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
495
|
+
id,
|
|
496
|
+
organizationId: scope.organizationId,
|
|
497
|
+
tenantId: scope.tenantId,
|
|
498
|
+
deletedAt: null,
|
|
499
|
+
})
|
|
500
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
501
|
+
|
|
502
|
+
sub.deletedAt = new Date()
|
|
503
|
+
await this.em.flush()
|
|
504
|
+
return { success: true }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async transitionSubscriptionStatus(id: string, targetStatus: string, scope: TenantScope) {
|
|
508
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
509
|
+
id,
|
|
510
|
+
organizationId: scope.organizationId,
|
|
511
|
+
tenantId: scope.tenantId,
|
|
512
|
+
deletedAt: null,
|
|
513
|
+
})
|
|
514
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
515
|
+
|
|
516
|
+
const currentStatus = sub.status as InventorySubscriptionStatus
|
|
517
|
+
const allowed = INVENTORY_SUBSCRIPTION_TRANSITIONS[currentStatus] ?? []
|
|
518
|
+
if (!allowed.includes(targetStatus as InventorySubscriptionStatus)) {
|
|
519
|
+
throw { status: 409, error: `Cannot transition from '${sub.status}' to '${targetStatus}'` }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const now = new Date()
|
|
523
|
+
|
|
524
|
+
if (targetStatus === 'active' && !sub.activatedAt) {
|
|
525
|
+
sub.activatedAt = now
|
|
526
|
+
}
|
|
527
|
+
if (targetStatus === 'suspended') {
|
|
528
|
+
sub.suspendedAt = now
|
|
529
|
+
}
|
|
530
|
+
if (currentStatus === 'suspended' && targetStatus === 'active') {
|
|
531
|
+
sub.suspendedAt = null
|
|
532
|
+
}
|
|
533
|
+
if (targetStatus === 'terminated' || targetStatus === 'superseded') {
|
|
534
|
+
sub.terminatedAt = now
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const items = await this.em.find(CpqInventorySubscriptionItem, {
|
|
538
|
+
subscriptionId: sub.id,
|
|
539
|
+
organizationId: scope.organizationId,
|
|
540
|
+
tenantId: scope.tenantId,
|
|
541
|
+
deletedAt: null,
|
|
542
|
+
status: { $nin: ['terminated', 'expired', 'superseded'] },
|
|
543
|
+
})
|
|
544
|
+
for (const item of items) {
|
|
545
|
+
item.status = targetStatus
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (targetStatus === 'active' || targetStatus === 'terminated' || targetStatus === 'superseded') {
|
|
549
|
+
const assetTargetStatus = targetStatus === 'active' ? 'active' : 'cancelled'
|
|
550
|
+
const excludeStatuses = targetStatus === 'active'
|
|
551
|
+
? ['returned']
|
|
552
|
+
: ['returned', 'cancelled']
|
|
553
|
+
const assets = await this.em.find(CpqInventoryAsset, {
|
|
554
|
+
subscriptionId: sub.id,
|
|
555
|
+
organizationId: scope.organizationId,
|
|
556
|
+
tenantId: scope.tenantId,
|
|
557
|
+
deletedAt: null,
|
|
558
|
+
status: { $nin: excludeStatuses },
|
|
559
|
+
})
|
|
560
|
+
for (const asset of assets) {
|
|
561
|
+
asset.status = assetTargetStatus
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
sub.status = targetStatus
|
|
566
|
+
await this.em.flush()
|
|
567
|
+
return sub
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ═══════════════════════════════════════════════════════════════
|
|
571
|
+
// Subscription Items
|
|
572
|
+
// ═══════════════════════════════════════════════════════════════
|
|
573
|
+
|
|
574
|
+
async listSubscriptionItems(filters: SubscriptionItemListFilters, scope: TenantScope) {
|
|
575
|
+
const where: Record<string, unknown> = {
|
|
576
|
+
organizationId: scope.organizationId,
|
|
577
|
+
tenantId: scope.tenantId,
|
|
578
|
+
deletedAt: null,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (filters.subscriptionId) where.subscriptionId = filters.subscriptionId
|
|
582
|
+
if (filters.parentItemId) where.parentItemId = filters.parentItemId
|
|
583
|
+
if (filters.status) where.status = filters.status
|
|
584
|
+
|
|
585
|
+
return this.em.find(CpqInventorySubscriptionItem, where, {
|
|
586
|
+
orderBy: { sortOrder: 'asc' },
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async addSubscriptionItem(subscriptionId: string, input: CreateSubscriptionItemInput, scope: TenantScope) {
|
|
591
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
592
|
+
id: subscriptionId,
|
|
593
|
+
organizationId: scope.organizationId,
|
|
594
|
+
tenantId: scope.tenantId,
|
|
595
|
+
deletedAt: null,
|
|
596
|
+
})
|
|
597
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
598
|
+
|
|
599
|
+
if (input.parentItemId) {
|
|
600
|
+
const parent = await this.em.findOne(CpqInventorySubscriptionItem, {
|
|
601
|
+
id: input.parentItemId,
|
|
602
|
+
organizationId: scope.organizationId,
|
|
603
|
+
tenantId: scope.tenantId,
|
|
604
|
+
deletedAt: null,
|
|
605
|
+
})
|
|
606
|
+
if (!parent) throw { status: 404, error: 'Parent item not found' }
|
|
607
|
+
if (parent.subscriptionId !== subscriptionId) {
|
|
608
|
+
throw { status: 409, error: 'Parent item does not belong to this subscription' }
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const item = this.em.create(CpqInventorySubscriptionItem, {
|
|
613
|
+
organizationId: scope.organizationId,
|
|
614
|
+
tenantId: scope.tenantId,
|
|
615
|
+
subscriptionId,
|
|
616
|
+
customerId: sub.customerId,
|
|
617
|
+
parentItemId: input.parentItemId ?? null,
|
|
618
|
+
productId: input.productId ?? null,
|
|
619
|
+
offeringId: input.offeringId ?? null,
|
|
620
|
+
specId: input.specId ?? null,
|
|
621
|
+
name: input.name,
|
|
622
|
+
status: 'pending',
|
|
623
|
+
configuration: input.configuration ?? {},
|
|
624
|
+
charges: input.charges ?? null,
|
|
625
|
+
mrcAmount: String(input.mrcAmount ?? 0),
|
|
626
|
+
nrcAmount: String(input.nrcAmount ?? 0),
|
|
627
|
+
currencyCode: input.currencyCode ?? sub.currencyCode,
|
|
628
|
+
quantity: input.quantity ?? 1,
|
|
629
|
+
capacityTotal: input.capacityTotal != null ? String(input.capacityTotal) : null,
|
|
630
|
+
capacityUsed: null,
|
|
631
|
+
capacityUnit: input.capacityUnit ?? null,
|
|
632
|
+
sourceQuoteLineId: input.sourceQuoteLineId ?? null,
|
|
633
|
+
metadata: input.metadata ?? null,
|
|
634
|
+
sortOrder: input.sortOrder ?? 0,
|
|
635
|
+
})
|
|
636
|
+
this.em.persist(item)
|
|
637
|
+
|
|
638
|
+
await this.em.flush()
|
|
639
|
+
await this.recomputeSubscriptionAggregates(subscriptionId, scope)
|
|
640
|
+
|
|
641
|
+
return item
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async updateSubscriptionItem(id: string, updates: UpdateSubscriptionItemInput, scope: TenantScope) {
|
|
645
|
+
const item = await this.em.findOne(CpqInventorySubscriptionItem, {
|
|
646
|
+
id,
|
|
647
|
+
organizationId: scope.organizationId,
|
|
648
|
+
tenantId: scope.tenantId,
|
|
649
|
+
deletedAt: null,
|
|
650
|
+
})
|
|
651
|
+
if (!item) throw { status: 404, error: 'Subscription item not found' }
|
|
652
|
+
|
|
653
|
+
if (updates.name !== undefined) item.name = updates.name
|
|
654
|
+
if (updates.metadata !== undefined) item.metadata = updates.metadata
|
|
655
|
+
if (updates.capacityTotal !== undefined) item.capacityTotal = updates.capacityTotal != null ? String(updates.capacityTotal) : null
|
|
656
|
+
if (updates.capacityUsed !== undefined) item.capacityUsed = updates.capacityUsed != null ? String(updates.capacityUsed) : null
|
|
657
|
+
if (updates.capacityUnit !== undefined) item.capacityUnit = updates.capacityUnit
|
|
658
|
+
if (updates.sortOrder !== undefined) item.sortOrder = updates.sortOrder
|
|
659
|
+
|
|
660
|
+
await this.em.flush()
|
|
661
|
+
return item
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async deleteSubscriptionItem(id: string, scope: TenantScope) {
|
|
665
|
+
const item = await this.em.findOne(CpqInventorySubscriptionItem, {
|
|
666
|
+
id,
|
|
667
|
+
organizationId: scope.organizationId,
|
|
668
|
+
tenantId: scope.tenantId,
|
|
669
|
+
deletedAt: null,
|
|
670
|
+
})
|
|
671
|
+
if (!item) throw { status: 404, error: 'Subscription item not found' }
|
|
672
|
+
|
|
673
|
+
item.deletedAt = new Date()
|
|
674
|
+
await this.em.flush()
|
|
675
|
+
await this.recomputeSubscriptionAggregates(item.subscriptionId, scope)
|
|
676
|
+
|
|
677
|
+
return { success: true }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ═══════════════════════════════════════════════════════════════
|
|
681
|
+
// Assets
|
|
682
|
+
// ═══════════════════════════════════════════════════════════════
|
|
683
|
+
|
|
684
|
+
async listAssets(filters: AssetListFilters, scope: TenantScope): Promise<PaginatedResult<CpqInventoryAsset>> {
|
|
685
|
+
const page = Math.max(1, filters.page ?? 1)
|
|
686
|
+
const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
|
|
687
|
+
const offset = (page - 1) * pageSize
|
|
688
|
+
|
|
689
|
+
const where: Record<string, unknown> = {
|
|
690
|
+
organizationId: scope.organizationId,
|
|
691
|
+
tenantId: scope.tenantId,
|
|
692
|
+
deletedAt: null,
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (filters.customerId) where.customerId = filters.customerId
|
|
696
|
+
if (filters.status) {
|
|
697
|
+
where.status = Array.isArray(filters.status)
|
|
698
|
+
? { $in: filters.status }
|
|
699
|
+
: filters.status
|
|
700
|
+
}
|
|
701
|
+
if (filters.assetType) where.assetType = filters.assetType
|
|
702
|
+
if (filters.productId) where.productId = filters.productId
|
|
703
|
+
if (filters.subscriptionId) where.subscriptionId = filters.subscriptionId
|
|
704
|
+
if (filters.subscriptionItemId) where.subscriptionItemId = filters.subscriptionItemId
|
|
705
|
+
|
|
706
|
+
const search = filters.search?.trim()
|
|
707
|
+
if (search) {
|
|
708
|
+
where.$or = [
|
|
709
|
+
{ code: { $ilike: `%${search}%` } },
|
|
710
|
+
{ name: { $ilike: `%${search}%` } },
|
|
711
|
+
]
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const sortField = filters.sortField ?? 'createdAt'
|
|
715
|
+
const sortDir = filters.sortDir === 'asc' ? 'asc' : 'desc'
|
|
716
|
+
|
|
717
|
+
const [items, total] = await this.em.findAndCount(
|
|
718
|
+
CpqInventoryAsset,
|
|
719
|
+
where,
|
|
720
|
+
{ limit: pageSize, offset, orderBy: { [sortField]: sortDir } },
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
items,
|
|
725
|
+
total,
|
|
726
|
+
page,
|
|
727
|
+
pageSize,
|
|
728
|
+
totalPages: Math.ceil(total / pageSize),
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async getAsset(assetId: string, scope: TenantScope) {
|
|
733
|
+
const asset = await this.em.findOne(CpqInventoryAsset, {
|
|
734
|
+
id: assetId,
|
|
735
|
+
organizationId: scope.organizationId,
|
|
736
|
+
tenantId: scope.tenantId,
|
|
737
|
+
deletedAt: null,
|
|
738
|
+
})
|
|
739
|
+
if (!asset) throw { status: 404, error: 'Asset not found' }
|
|
740
|
+
return asset
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async createAsset(input: CreateAssetInput, scope: TenantScope) {
|
|
744
|
+
const code = input.code ?? await this.generateAssetCode(scope)
|
|
745
|
+
|
|
746
|
+
if (input.subscriptionId) {
|
|
747
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
748
|
+
id: input.subscriptionId,
|
|
749
|
+
organizationId: scope.organizationId,
|
|
750
|
+
tenantId: scope.tenantId,
|
|
751
|
+
deletedAt: null,
|
|
752
|
+
})
|
|
753
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (input.subscriptionItemId) {
|
|
757
|
+
const item = await this.em.findOne(CpqInventorySubscriptionItem, {
|
|
758
|
+
id: input.subscriptionItemId,
|
|
759
|
+
organizationId: scope.organizationId,
|
|
760
|
+
tenantId: scope.tenantId,
|
|
761
|
+
deletedAt: null,
|
|
762
|
+
})
|
|
763
|
+
if (!item) throw { status: 404, error: 'Subscription item not found' }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const asset = this.em.create(CpqInventoryAsset, {
|
|
767
|
+
organizationId: scope.organizationId,
|
|
768
|
+
tenantId: scope.tenantId,
|
|
769
|
+
customerId: input.customerId,
|
|
770
|
+
subscriptionId: input.subscriptionId ?? null,
|
|
771
|
+
subscriptionItemId: input.subscriptionItemId ?? null,
|
|
772
|
+
productId: input.productId ?? null,
|
|
773
|
+
offeringId: input.offeringId ?? null,
|
|
774
|
+
specId: input.specId ?? null,
|
|
775
|
+
code,
|
|
776
|
+
name: input.name,
|
|
777
|
+
description: input.description ?? null,
|
|
778
|
+
status: 'pending',
|
|
779
|
+
assetType: input.assetType ?? 'one_time',
|
|
780
|
+
currencyCode: input.currencyCode ?? await getBaseCurrencyCode(this.em, scope),
|
|
781
|
+
purchasePrice: String(input.purchasePrice ?? 0),
|
|
782
|
+
quantity: input.quantity ?? 1,
|
|
783
|
+
purchaseDate: input.purchaseDate ? new Date(input.purchaseDate) : null,
|
|
784
|
+
deliveryDate: input.deliveryDate ? new Date(input.deliveryDate) : null,
|
|
785
|
+
configuration: input.configuration ?? {},
|
|
786
|
+
charges: input.charges ?? null,
|
|
787
|
+
sourceQuoteId: input.sourceQuoteId ?? null,
|
|
788
|
+
sourceOrderId: input.sourceOrderId ?? null,
|
|
789
|
+
sourceQuoteLineId: input.sourceQuoteLineId ?? null,
|
|
790
|
+
metadata: input.metadata ?? null,
|
|
791
|
+
})
|
|
792
|
+
this.em.persist(asset)
|
|
793
|
+
|
|
794
|
+
await this.em.flush()
|
|
795
|
+
return asset
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async updateAsset(id: string, updates: UpdateAssetInput, scope: TenantScope) {
|
|
799
|
+
const asset = await this.em.findOne(CpqInventoryAsset, {
|
|
800
|
+
id,
|
|
801
|
+
organizationId: scope.organizationId,
|
|
802
|
+
tenantId: scope.tenantId,
|
|
803
|
+
deletedAt: null,
|
|
804
|
+
})
|
|
805
|
+
if (!asset) throw { status: 404, error: 'Asset not found' }
|
|
806
|
+
|
|
807
|
+
if (updates.name !== undefined) asset.name = updates.name
|
|
808
|
+
if (updates.description !== undefined) asset.description = updates.description
|
|
809
|
+
if (updates.assetType !== undefined) asset.assetType = updates.assetType
|
|
810
|
+
if (updates.deliveryDate !== undefined) asset.deliveryDate = updates.deliveryDate ? new Date(updates.deliveryDate) : null
|
|
811
|
+
if (updates.metadata !== undefined) asset.metadata = updates.metadata
|
|
812
|
+
|
|
813
|
+
await this.em.flush()
|
|
814
|
+
return asset
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async deleteAsset(id: string, scope: TenantScope) {
|
|
818
|
+
const asset = await this.em.findOne(CpqInventoryAsset, {
|
|
819
|
+
id,
|
|
820
|
+
organizationId: scope.organizationId,
|
|
821
|
+
tenantId: scope.tenantId,
|
|
822
|
+
deletedAt: null,
|
|
823
|
+
})
|
|
824
|
+
if (!asset) throw { status: 404, error: 'Asset not found' }
|
|
825
|
+
|
|
826
|
+
asset.deletedAt = new Date()
|
|
827
|
+
await this.em.flush()
|
|
828
|
+
return { success: true }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async transitionAssetStatus(id: string, targetStatus: string, scope: TenantScope) {
|
|
832
|
+
const asset = await this.em.findOne(CpqInventoryAsset, {
|
|
833
|
+
id,
|
|
834
|
+
organizationId: scope.organizationId,
|
|
835
|
+
tenantId: scope.tenantId,
|
|
836
|
+
deletedAt: null,
|
|
837
|
+
})
|
|
838
|
+
if (!asset) throw { status: 404, error: 'Asset not found' }
|
|
839
|
+
|
|
840
|
+
const currentStatus = asset.status as InventoryAssetStatus
|
|
841
|
+
const allowed = INVENTORY_ASSET_TRANSITIONS[currentStatus] ?? []
|
|
842
|
+
if (!allowed.includes(targetStatus as InventoryAssetStatus)) {
|
|
843
|
+
throw { status: 409, error: `Cannot transition from '${asset.status}' to '${targetStatus}'` }
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
asset.status = targetStatus
|
|
847
|
+
await this.em.flush()
|
|
848
|
+
return asset
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ═══════════════════════════════════════════════════════════════
|
|
852
|
+
// Customer View
|
|
853
|
+
// ═══════════════════════════════════════════════════════════════
|
|
854
|
+
|
|
855
|
+
async getCustomerInventory(customerId: string, filters: CustomerInventoryFilters, scope: TenantScope) {
|
|
856
|
+
const baseWhere: Record<string, unknown> = {
|
|
857
|
+
organizationId: scope.organizationId,
|
|
858
|
+
tenantId: scope.tenantId,
|
|
859
|
+
deletedAt: null,
|
|
860
|
+
customerId,
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const subTerminalStatuses = ['terminated', 'expired']
|
|
864
|
+
const itemTerminalStatuses = ['terminated', 'expired']
|
|
865
|
+
const assetTerminalStatuses = ['returned']
|
|
866
|
+
|
|
867
|
+
const subWhere: Record<string, unknown> = { ...baseWhere }
|
|
868
|
+
const itemWhere: Record<string, unknown> = { ...baseWhere }
|
|
869
|
+
const assetWhere: Record<string, unknown> = { ...baseWhere }
|
|
870
|
+
|
|
871
|
+
if (filters.status) {
|
|
872
|
+
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
|
|
873
|
+
subWhere.status = { $in: statuses }
|
|
874
|
+
itemWhere.status = { $in: statuses }
|
|
875
|
+
assetWhere.status = { $in: statuses }
|
|
876
|
+
} else if (!filters.includeTerminated) {
|
|
877
|
+
subWhere.status = { $nin: subTerminalStatuses }
|
|
878
|
+
itemWhere.status = { $nin: itemTerminalStatuses }
|
|
879
|
+
assetWhere.status = { $nin: assetTerminalStatuses }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (filters.productId) {
|
|
883
|
+
subWhere.id = { $in: await this.subscriptionIdsForProduct(filters.productId, scope) }
|
|
884
|
+
itemWhere.productId = filters.productId
|
|
885
|
+
assetWhere.productId = filters.productId
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const [subscriptions, subscriptionItems, assets] = await Promise.all([
|
|
889
|
+
this.em.find(CpqInventorySubscription, subWhere, { orderBy: { createdAt: 'desc' } }),
|
|
890
|
+
this.em.find(CpqInventorySubscriptionItem, itemWhere, { orderBy: { sortOrder: 'asc' } }),
|
|
891
|
+
this.em.find(CpqInventoryAsset, assetWhere, { orderBy: { createdAt: 'desc' } }),
|
|
892
|
+
])
|
|
893
|
+
|
|
894
|
+
const activeSubscriptions = subscriptions.filter((s) => s.status === 'active').length
|
|
895
|
+
const totalMrc = subscriptions
|
|
896
|
+
.filter((s) => s.status === 'active')
|
|
897
|
+
.reduce((sum, s) => sum + Number(s.mrcAmount), 0)
|
|
898
|
+
const activeAssets = assets.filter((a) => a.status === 'active').length
|
|
899
|
+
const pendingItems = subscriptionItems.filter((i) => i.status === 'pending').length
|
|
900
|
+
|
|
901
|
+
const currencyCode = subscriptions[0]?.currencyCode ?? assets[0]?.currencyCode ?? await getBaseCurrencyCode(this.em, scope)
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
customerId,
|
|
905
|
+
subscriptions,
|
|
906
|
+
subscriptionItems,
|
|
907
|
+
assets,
|
|
908
|
+
summary: {
|
|
909
|
+
activeSubscriptions,
|
|
910
|
+
totalMrc,
|
|
911
|
+
activeAssets,
|
|
912
|
+
pendingItems,
|
|
913
|
+
currencyCode,
|
|
914
|
+
},
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async getCustomerParentItems(
|
|
919
|
+
customerId: string,
|
|
920
|
+
filters: { productId?: string; offeringId?: string },
|
|
921
|
+
scope: TenantScope,
|
|
922
|
+
) {
|
|
923
|
+
const where: Record<string, unknown> = {
|
|
924
|
+
organizationId: scope.organizationId,
|
|
925
|
+
tenantId: scope.tenantId,
|
|
926
|
+
deletedAt: null,
|
|
927
|
+
customerId,
|
|
928
|
+
parentItemId: null,
|
|
929
|
+
status: 'active',
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (filters.productId) where.productId = filters.productId
|
|
933
|
+
if (filters.offeringId) where.offeringId = filters.offeringId
|
|
934
|
+
|
|
935
|
+
return this.em.find(CpqInventorySubscriptionItem, where, {
|
|
936
|
+
orderBy: { sortOrder: 'asc' },
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ═══════════════════════════════════════════════════════════════
|
|
941
|
+
// ARC (Amend / Renew / Cancel) — XD-250
|
|
942
|
+
//
|
|
943
|
+
// Mutate-in-place primitives invoked by cpqOrderService.activateOrder
|
|
944
|
+
// per attached target subscription. Each method:
|
|
945
|
+
// • is idempotent on (sourceOrderId, subscriptionId), enforced via the
|
|
946
|
+
// DB-level UNIQUE on cpq_subscription_change_logs;
|
|
947
|
+
// • takes optimistic lock on subscription.version (MikroORM auto-bumps);
|
|
948
|
+
// • writes a CpqSubscriptionChangeLog row and updates
|
|
949
|
+
// subscription.lastChangeLogId.
|
|
950
|
+
//
|
|
951
|
+
// Event emission is the caller's job (centralized in cpqOrderService
|
|
952
|
+
// AFTER the activation transaction commits).
|
|
953
|
+
// ═══════════════════════════════════════════════════════════════
|
|
954
|
+
|
|
955
|
+
async findExpiringSubscriptions(
|
|
956
|
+
filters: {
|
|
957
|
+
withinDays?: number
|
|
958
|
+
customerId?: string
|
|
959
|
+
billingCycle?: string
|
|
960
|
+
status?: string
|
|
961
|
+
page?: number
|
|
962
|
+
pageSize?: number
|
|
963
|
+
},
|
|
964
|
+
scope: TenantScope,
|
|
965
|
+
): Promise<PaginatedResult<CpqInventorySubscription>> {
|
|
966
|
+
const withinDays = Math.min(365, Math.max(1, filters.withinDays ?? 30))
|
|
967
|
+
const status = filters.status ?? 'active'
|
|
968
|
+
const page = Math.max(1, filters.page ?? 1)
|
|
969
|
+
const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
|
|
970
|
+
const offset = (page - 1) * pageSize
|
|
971
|
+
|
|
972
|
+
const now = new Date()
|
|
973
|
+
const horizon = new Date(now.getTime() + withinDays * 24 * 60 * 60 * 1000)
|
|
974
|
+
|
|
975
|
+
const where: Record<string, unknown> = {
|
|
976
|
+
organizationId: scope.organizationId,
|
|
977
|
+
tenantId: scope.tenantId,
|
|
978
|
+
deletedAt: null,
|
|
979
|
+
status,
|
|
980
|
+
currentTermEnd: { $gte: now, $lte: horizon },
|
|
981
|
+
}
|
|
982
|
+
if (filters.customerId) where.customerId = filters.customerId
|
|
983
|
+
if (filters.billingCycle) where.billingCycle = filters.billingCycle
|
|
984
|
+
|
|
985
|
+
const [items, total] = await this.em.findAndCount(
|
|
986
|
+
CpqInventorySubscription,
|
|
987
|
+
where,
|
|
988
|
+
{ limit: pageSize, offset, orderBy: { currentTermEnd: 'asc' } },
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async listChangeLog(
|
|
995
|
+
subscriptionId: string,
|
|
996
|
+
filters: { page?: number; pageSize?: number },
|
|
997
|
+
scope: TenantScope,
|
|
998
|
+
): Promise<PaginatedResult<CpqSubscriptionChangeLog>> {
|
|
999
|
+
const page = Math.max(1, filters.page ?? 1)
|
|
1000
|
+
const pageSize = Math.min(100, Math.max(1, filters.pageSize ?? 50))
|
|
1001
|
+
const offset = (page - 1) * pageSize
|
|
1002
|
+
|
|
1003
|
+
const where = {
|
|
1004
|
+
organizationId: scope.organizationId,
|
|
1005
|
+
tenantId: scope.tenantId,
|
|
1006
|
+
deletedAt: null,
|
|
1007
|
+
subscriptionId,
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const [items, total] = await this.em.findAndCount(
|
|
1011
|
+
CpqSubscriptionChangeLog,
|
|
1012
|
+
where,
|
|
1013
|
+
{ limit: pageSize, offset, orderBy: { createdAt: 'desc' } },
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async applyAmendment(input: ApplyAmendmentInput, scope: TenantScope): Promise<ApplyArcResult> {
|
|
1020
|
+
const idempotent = await this.findIdempotentChangeLog(
|
|
1021
|
+
input.sourceOrderId,
|
|
1022
|
+
input.subscriptionId,
|
|
1023
|
+
scope,
|
|
1024
|
+
)
|
|
1025
|
+
if (idempotent) {
|
|
1026
|
+
const sub = await this.requireSubscription(input.subscriptionId, scope)
|
|
1027
|
+
return { changeLog: idempotent, subscription: sub }
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const sub = await this.requireSubscription(input.subscriptionId, scope)
|
|
1031
|
+
const beforeSnapshot = await this.snapshotSubscription(sub, scope)
|
|
1032
|
+
|
|
1033
|
+
const { lineChangeSummary } = await this.applyLineChanges(
|
|
1034
|
+
sub,
|
|
1035
|
+
input.lineChanges,
|
|
1036
|
+
scope,
|
|
1037
|
+
null,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
await this.recomputeSubscriptionAggregatesInPlace(sub, scope)
|
|
1041
|
+
|
|
1042
|
+
await this.em.flush()
|
|
1043
|
+
|
|
1044
|
+
const afterSnapshot = await this.snapshotSubscription(sub, scope)
|
|
1045
|
+
|
|
1046
|
+
const log = this.createChangeLog({
|
|
1047
|
+
subscription: sub,
|
|
1048
|
+
changeType: 'amend',
|
|
1049
|
+
sourceQuoteId: input.sourceQuoteId,
|
|
1050
|
+
sourceOrderId: input.sourceOrderId,
|
|
1051
|
+
performedByUserId: input.performedByUserId,
|
|
1052
|
+
beforeSnapshot,
|
|
1053
|
+
afterSnapshot,
|
|
1054
|
+
lineChanges: lineChangeSummary,
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
sub.lastChangeLogId = log.id
|
|
1058
|
+
await this.em.flush()
|
|
1059
|
+
|
|
1060
|
+
return { changeLog: log, subscription: sub }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async applyRenewal(input: ApplyRenewalInput, scope: TenantScope): Promise<ApplyArcResult> {
|
|
1064
|
+
const idempotent = await this.findIdempotentChangeLog(
|
|
1065
|
+
input.sourceOrderId,
|
|
1066
|
+
input.subscriptionId,
|
|
1067
|
+
scope,
|
|
1068
|
+
)
|
|
1069
|
+
if (idempotent) {
|
|
1070
|
+
const sub = await this.requireSubscription(input.subscriptionId, scope)
|
|
1071
|
+
return { changeLog: idempotent, subscription: sub }
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const sub = await this.requireSubscription(input.subscriptionId, scope)
|
|
1075
|
+
const beforeSnapshot = await this.snapshotSubscription(sub, scope)
|
|
1076
|
+
const oldTermStart = sub.currentTermStart
|
|
1077
|
+
const oldTermEnd = sub.currentTermEnd
|
|
1078
|
+
const oldTermMonths = sub.termMonths
|
|
1079
|
+
|
|
1080
|
+
const newStart = toDate(input.term.newTermStart)
|
|
1081
|
+
const newEnd = toDate(input.term.newTermEnd)
|
|
1082
|
+
sub.currentTermStart = newStart
|
|
1083
|
+
sub.currentTermEnd = newEnd
|
|
1084
|
+
if (input.term.newTermMonths != null) sub.termMonths = input.term.newTermMonths
|
|
1085
|
+
|
|
1086
|
+
const { lineChangeSummary } = await this.applyLineChanges(
|
|
1087
|
+
sub,
|
|
1088
|
+
input.lineChanges,
|
|
1089
|
+
scope,
|
|
1090
|
+
null,
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
await this.recomputeSubscriptionAggregatesInPlace(sub, scope)
|
|
1094
|
+
|
|
1095
|
+
await this.em.flush()
|
|
1096
|
+
|
|
1097
|
+
const afterSnapshot = await this.snapshotSubscription(sub, scope)
|
|
1098
|
+
|
|
1099
|
+
const log = this.createChangeLog({
|
|
1100
|
+
subscription: sub,
|
|
1101
|
+
changeType: 'renew',
|
|
1102
|
+
sourceQuoteId: input.sourceQuoteId,
|
|
1103
|
+
sourceOrderId: input.sourceOrderId,
|
|
1104
|
+
performedByUserId: input.performedByUserId,
|
|
1105
|
+
beforeSnapshot,
|
|
1106
|
+
afterSnapshot,
|
|
1107
|
+
lineChanges: lineChangeSummary,
|
|
1108
|
+
termChange: {
|
|
1109
|
+
oldTermStart: toIsoOrNull(oldTermStart),
|
|
1110
|
+
newTermStart: newStart.toISOString(),
|
|
1111
|
+
oldTermEnd: toIsoOrNull(oldTermEnd),
|
|
1112
|
+
newTermEnd: newEnd.toISOString(),
|
|
1113
|
+
oldTermMonths: oldTermMonths ?? null,
|
|
1114
|
+
newTermMonths: input.term.newTermMonths ?? oldTermMonths ?? null,
|
|
1115
|
+
},
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
sub.lastChangeLogId = log.id
|
|
1119
|
+
await this.em.flush()
|
|
1120
|
+
|
|
1121
|
+
return { changeLog: log, subscription: sub }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async applyMergeRenewal(
|
|
1125
|
+
input: ApplyMergeRenewalInput,
|
|
1126
|
+
scope: TenantScope,
|
|
1127
|
+
): Promise<ApplyMergeRenewalResult> {
|
|
1128
|
+
if (input.sourceIds.length < 2) {
|
|
1129
|
+
throw { status: 400, error: 'Merge requires at least two source subscriptions' }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Idempotency: if every source already has a merge-source ChangeLog row
|
|
1133
|
+
// for this order, the merge has been applied — re-fetch and return.
|
|
1134
|
+
const existingSourceLogs = await this.em.find(CpqSubscriptionChangeLog, {
|
|
1135
|
+
organizationId: scope.organizationId,
|
|
1136
|
+
tenantId: scope.tenantId,
|
|
1137
|
+
deletedAt: null,
|
|
1138
|
+
sourceOrderId: input.sourceOrderId,
|
|
1139
|
+
subscriptionId: { $in: input.sourceIds },
|
|
1140
|
+
changeType: 'merge-source',
|
|
1141
|
+
})
|
|
1142
|
+
if (existingSourceLogs.length === input.sourceIds.length) {
|
|
1143
|
+
const mergeResult = await this.em.findOne(CpqSubscriptionChangeLog, {
|
|
1144
|
+
organizationId: scope.organizationId,
|
|
1145
|
+
tenantId: scope.tenantId,
|
|
1146
|
+
deletedAt: null,
|
|
1147
|
+
sourceOrderId: input.sourceOrderId,
|
|
1148
|
+
changeType: 'merge-result',
|
|
1149
|
+
})
|
|
1150
|
+
if (mergeResult) {
|
|
1151
|
+
const m = await this.requireSubscription(mergeResult.subscriptionId, scope)
|
|
1152
|
+
return {
|
|
1153
|
+
mergedSubscription: m,
|
|
1154
|
+
mergeResultChangeLog: mergeResult,
|
|
1155
|
+
sourceChangeLogs: existingSourceLogs,
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Validate sources: same customer/currency/billingCycle, all in {active, suspended}.
|
|
1161
|
+
const sources = await this.em.find(CpqInventorySubscription, {
|
|
1162
|
+
id: { $in: input.sourceIds },
|
|
1163
|
+
organizationId: scope.organizationId,
|
|
1164
|
+
tenantId: scope.tenantId,
|
|
1165
|
+
deletedAt: null,
|
|
1166
|
+
})
|
|
1167
|
+
if (sources.length !== input.sourceIds.length) {
|
|
1168
|
+
throw { status: 404, error: 'One or more source subscriptions not found' }
|
|
1169
|
+
}
|
|
1170
|
+
const customers = new Set(sources.map((s) => s.customerId))
|
|
1171
|
+
const currencies = new Set(sources.map((s) => s.currencyCode))
|
|
1172
|
+
const cycles = new Set(sources.map((s) => s.billingCycle))
|
|
1173
|
+
if (customers.size > 1 || currencies.size > 1 || cycles.size > 1) {
|
|
1174
|
+
throw {
|
|
1175
|
+
status: 400,
|
|
1176
|
+
error: 'Merge sources must share customer, currency, and billing cycle',
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
for (const s of sources) {
|
|
1180
|
+
if (s.status !== 'active' && s.status !== 'suspended') {
|
|
1181
|
+
throw {
|
|
1182
|
+
status: 409,
|
|
1183
|
+
error: `Source subscription ${s.id} is in '${s.status}' status — must be active or suspended to merge`,
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
const customerId = sources[0].customerId
|
|
1189
|
+
const currencyCode = sources[0].currencyCode
|
|
1190
|
+
const billingCycle = sources[0].billingCycle
|
|
1191
|
+
|
|
1192
|
+
const beforeSnapshots = new Map<string, Record<string, unknown>>()
|
|
1193
|
+
for (const s of sources) {
|
|
1194
|
+
beforeSnapshots.set(s.id, await this.snapshotSubscription(s, scope))
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Create the new merge sub M.
|
|
1198
|
+
const newTermStart = toDate(input.term.newTermStart)
|
|
1199
|
+
const newTermEnd = toDate(input.term.newTermEnd)
|
|
1200
|
+
const code =
|
|
1201
|
+
input.mergeMeta.newSubCode ?? (await this.generateSubscriptionCode(scope))
|
|
1202
|
+
const name = input.mergeMeta.newSubName ?? `Merged contract`
|
|
1203
|
+
|
|
1204
|
+
// Pre-generate id so child rows referenced before flush (carry-over items
|
|
1205
|
+
// FK to subscriptionId, asset migration FK update) don't depend on the
|
|
1206
|
+
// intervening flush() to populate it. Matches the createSubscription pattern.
|
|
1207
|
+
const newSubId = crypto.randomUUID()
|
|
1208
|
+
const m = this.em.create(CpqInventorySubscription, {
|
|
1209
|
+
id: newSubId,
|
|
1210
|
+
organizationId: scope.organizationId,
|
|
1211
|
+
tenantId: scope.tenantId,
|
|
1212
|
+
customerId,
|
|
1213
|
+
code,
|
|
1214
|
+
name,
|
|
1215
|
+
status: 'active',
|
|
1216
|
+
billingCycle,
|
|
1217
|
+
currencyCode,
|
|
1218
|
+
mrcAmount: '0',
|
|
1219
|
+
nrcAmount: '0',
|
|
1220
|
+
currentTermStart: newTermStart,
|
|
1221
|
+
currentTermEnd: newTermEnd,
|
|
1222
|
+
termMonths: input.term.newTermMonths ?? null,
|
|
1223
|
+
autoRenew: true,
|
|
1224
|
+
activatedAt: new Date(),
|
|
1225
|
+
sourceQuoteId: input.sourceQuoteId,
|
|
1226
|
+
sourceOrderId: input.sourceOrderId,
|
|
1227
|
+
})
|
|
1228
|
+
this.em.persist(m)
|
|
1229
|
+
await this.em.flush()
|
|
1230
|
+
|
|
1231
|
+
// Carry-over phase: clone every source's active items onto M.
|
|
1232
|
+
// Build sourceItemId → newItemIdOnM map for line resolution.
|
|
1233
|
+
const sourceItemToNewItem = new Map<string, string>()
|
|
1234
|
+
|
|
1235
|
+
for (const source of sources) {
|
|
1236
|
+
const activeItems = await this.em.find(CpqInventorySubscriptionItem, {
|
|
1237
|
+
subscriptionId: source.id,
|
|
1238
|
+
organizationId: scope.organizationId,
|
|
1239
|
+
tenantId: scope.tenantId,
|
|
1240
|
+
deletedAt: null,
|
|
1241
|
+
status: { $nin: ['terminated', 'expired', 'superseded'] },
|
|
1242
|
+
})
|
|
1243
|
+
for (const item of activeItems) {
|
|
1244
|
+
// Pre-generate the cloned item id — `defaultRaw: 'gen_random_uuid()'`
|
|
1245
|
+
// only fires at INSERT, so without this `cloned.id` is undefined right
|
|
1246
|
+
// after em.create(). Then `sourceItemToNewItem.set(item.id, cloned.id)`
|
|
1247
|
+
// would store undefined and the line-change translation below would
|
|
1248
|
+
// fall back to the source-sub item id, which doesn't exist on the new
|
|
1249
|
+
// merged sub M → applyLineChanges throws 404 at activation.
|
|
1250
|
+
const clonedId = crypto.randomUUID()
|
|
1251
|
+
const cloned = this.em.create(CpqInventorySubscriptionItem, {
|
|
1252
|
+
id: clonedId,
|
|
1253
|
+
organizationId: scope.organizationId,
|
|
1254
|
+
tenantId: scope.tenantId,
|
|
1255
|
+
subscriptionId: m.id,
|
|
1256
|
+
customerId,
|
|
1257
|
+
parentItemId: null,
|
|
1258
|
+
productId: item.productId ?? null,
|
|
1259
|
+
offeringId: item.offeringId ?? null,
|
|
1260
|
+
specId: item.specId ?? null,
|
|
1261
|
+
name: item.name,
|
|
1262
|
+
status: 'active',
|
|
1263
|
+
configuration: { ...item.configuration },
|
|
1264
|
+
charges: item.charges ? [...item.charges] : null,
|
|
1265
|
+
mrcAmount: item.mrcAmount,
|
|
1266
|
+
nrcAmount: item.nrcAmount,
|
|
1267
|
+
currencyCode: item.currencyCode,
|
|
1268
|
+
quantity: item.quantity,
|
|
1269
|
+
capacityTotal: item.capacityTotal,
|
|
1270
|
+
capacityUsed: item.capacityUsed,
|
|
1271
|
+
capacityUnit: item.capacityUnit,
|
|
1272
|
+
// Carry-over marker: sourceQuoteLineId stays null so the operator can
|
|
1273
|
+
// distinguish from operator-add lines later.
|
|
1274
|
+
sourceQuoteLineId: null,
|
|
1275
|
+
metadata: item.metadata,
|
|
1276
|
+
sortOrder: item.sortOrder,
|
|
1277
|
+
})
|
|
1278
|
+
this.em.persist(cloned)
|
|
1279
|
+
sourceItemToNewItem.set(item.id, clonedId)
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
await this.em.flush()
|
|
1283
|
+
|
|
1284
|
+
// Migrate assets: every source's assets switch FK to M (and to the new
|
|
1285
|
+
// item id mapped from the original source item id).
|
|
1286
|
+
for (const source of sources) {
|
|
1287
|
+
const assets = await this.em.find(CpqInventoryAsset, {
|
|
1288
|
+
subscriptionId: source.id,
|
|
1289
|
+
organizationId: scope.organizationId,
|
|
1290
|
+
tenantId: scope.tenantId,
|
|
1291
|
+
deletedAt: null,
|
|
1292
|
+
status: { $nin: ['returned', 'cancelled'] },
|
|
1293
|
+
})
|
|
1294
|
+
for (const asset of assets) {
|
|
1295
|
+
asset.subscriptionId = m.id
|
|
1296
|
+
if (asset.subscriptionItemId) {
|
|
1297
|
+
const newItemId = sourceItemToNewItem.get(asset.subscriptionItemId)
|
|
1298
|
+
if (newItemId) asset.subscriptionItemId = newItemId
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
await this.em.flush()
|
|
1303
|
+
|
|
1304
|
+
// Operator-edit phase: apply lineChanges on M's combined item set.
|
|
1305
|
+
// For cancel/modify lines, sourceSubscriptionItemId references an item on
|
|
1306
|
+
// one of the source subs — translate to the corresponding new item on M.
|
|
1307
|
+
const translatedChanges: ArcLineChange[] = input.lineChanges.map((lc) => {
|
|
1308
|
+
if (
|
|
1309
|
+
(lc.action === 'cancel' || lc.action === 'modify') &&
|
|
1310
|
+
lc.sourceSubscriptionItemId
|
|
1311
|
+
) {
|
|
1312
|
+
const newItemId = sourceItemToNewItem.get(lc.sourceSubscriptionItemId)
|
|
1313
|
+
return {
|
|
1314
|
+
...lc,
|
|
1315
|
+
sourceSubscriptionItemId: newItemId ?? lc.sourceSubscriptionItemId,
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return lc
|
|
1319
|
+
})
|
|
1320
|
+
|
|
1321
|
+
const { lineChangeSummary } = await this.applyLineChanges(
|
|
1322
|
+
m,
|
|
1323
|
+
translatedChanges,
|
|
1324
|
+
scope,
|
|
1325
|
+
null,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
await this.recomputeSubscriptionAggregatesInPlace(m, scope)
|
|
1329
|
+
await this.em.flush()
|
|
1330
|
+
|
|
1331
|
+
const afterSnapshot = await this.snapshotSubscription(m, scope)
|
|
1332
|
+
|
|
1333
|
+
// Retire each source: status='superseded', mergedIntoSubscriptionId=M.id,
|
|
1334
|
+
// items cascaded to 'superseded'.
|
|
1335
|
+
const sourceLogs: CpqSubscriptionChangeLog[] = []
|
|
1336
|
+
const now = new Date()
|
|
1337
|
+
for (const source of sources) {
|
|
1338
|
+
const items = await this.em.find(CpqInventorySubscriptionItem, {
|
|
1339
|
+
subscriptionId: source.id,
|
|
1340
|
+
organizationId: scope.organizationId,
|
|
1341
|
+
tenantId: scope.tenantId,
|
|
1342
|
+
deletedAt: null,
|
|
1343
|
+
status: { $nin: ['terminated', 'expired', 'superseded'] },
|
|
1344
|
+
})
|
|
1345
|
+
for (const item of items) {
|
|
1346
|
+
item.status = 'superseded'
|
|
1347
|
+
}
|
|
1348
|
+
source.status = 'superseded'
|
|
1349
|
+
source.mergedIntoSubscriptionId = m.id
|
|
1350
|
+
source.terminatedAt = now
|
|
1351
|
+
// Recompute aggregates: source has no active items left.
|
|
1352
|
+
source.mrcAmount = '0'
|
|
1353
|
+
|
|
1354
|
+
const sourceLog = this.createChangeLog({
|
|
1355
|
+
subscription: source,
|
|
1356
|
+
changeType: 'merge-source',
|
|
1357
|
+
sourceQuoteId: input.sourceQuoteId,
|
|
1358
|
+
sourceOrderId: input.sourceOrderId,
|
|
1359
|
+
performedByUserId: input.performedByUserId,
|
|
1360
|
+
beforeSnapshot: beforeSnapshots.get(source.id) ?? null,
|
|
1361
|
+
// afterSnapshot intentionally null — terminal state IS the meaningful "after".
|
|
1362
|
+
afterSnapshot: null,
|
|
1363
|
+
lineChanges: null,
|
|
1364
|
+
mergedIntoSubscriptionId: m.id,
|
|
1365
|
+
})
|
|
1366
|
+
source.lastChangeLogId = sourceLog.id
|
|
1367
|
+
sourceLogs.push(sourceLog)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const mergeResultLog = this.createChangeLog({
|
|
1371
|
+
subscription: m,
|
|
1372
|
+
changeType: 'merge-result',
|
|
1373
|
+
sourceQuoteId: input.sourceQuoteId,
|
|
1374
|
+
sourceOrderId: input.sourceOrderId,
|
|
1375
|
+
performedByUserId: input.performedByUserId,
|
|
1376
|
+
beforeSnapshot: null, // M is brand new — no "before" state.
|
|
1377
|
+
afterSnapshot,
|
|
1378
|
+
lineChanges: lineChangeSummary,
|
|
1379
|
+
termChange: {
|
|
1380
|
+
oldTermStart: null,
|
|
1381
|
+
newTermStart: newTermStart.toISOString(),
|
|
1382
|
+
oldTermEnd: null,
|
|
1383
|
+
newTermEnd: newTermEnd.toISOString(),
|
|
1384
|
+
oldTermMonths: null,
|
|
1385
|
+
newTermMonths: input.term.newTermMonths ?? null,
|
|
1386
|
+
},
|
|
1387
|
+
mergedFromSubscriptionIds: input.sourceIds,
|
|
1388
|
+
})
|
|
1389
|
+
m.lastChangeLogId = mergeResultLog.id
|
|
1390
|
+
|
|
1391
|
+
await this.em.flush()
|
|
1392
|
+
|
|
1393
|
+
return {
|
|
1394
|
+
mergedSubscription: m,
|
|
1395
|
+
mergeResultChangeLog: mergeResultLog,
|
|
1396
|
+
sourceChangeLogs: sourceLogs,
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
async applyCancel(input: ApplyCancelInput, scope: TenantScope): Promise<ApplyArcResult> {
|
|
1401
|
+
const idempotent = await this.findIdempotentChangeLog(
|
|
1402
|
+
input.sourceOrderId,
|
|
1403
|
+
input.subscriptionId,
|
|
1404
|
+
scope,
|
|
1405
|
+
)
|
|
1406
|
+
if (idempotent) {
|
|
1407
|
+
const sub = await this.requireSubscription(input.subscriptionId, scope)
|
|
1408
|
+
return { changeLog: idempotent, subscription: sub }
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const sub = await this.requireSubscription(input.subscriptionId, scope)
|
|
1412
|
+
if (sub.status !== 'active' && sub.status !== 'suspended') {
|
|
1413
|
+
throw {
|
|
1414
|
+
status: 409,
|
|
1415
|
+
error: `Cannot cancel subscription in '${sub.status}' status`,
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
const beforeSnapshot = await this.snapshotSubscription(sub, scope)
|
|
1419
|
+
|
|
1420
|
+
// Reuse the existing cascade. transitionSubscriptionStatus flushes; that's
|
|
1421
|
+
// fine — we'll snapshot again afterwards.
|
|
1422
|
+
await this.transitionSubscriptionStatus(sub.id, 'terminated', scope)
|
|
1423
|
+
|
|
1424
|
+
const reload = await this.requireSubscription(input.subscriptionId, scope)
|
|
1425
|
+
const afterSnapshot = await this.snapshotSubscription(reload, scope)
|
|
1426
|
+
|
|
1427
|
+
const log = this.createChangeLog({
|
|
1428
|
+
subscription: reload,
|
|
1429
|
+
changeType: 'cancel',
|
|
1430
|
+
sourceQuoteId: input.sourceQuoteId,
|
|
1431
|
+
sourceOrderId: input.sourceOrderId,
|
|
1432
|
+
performedByUserId: input.performedByUserId,
|
|
1433
|
+
beforeSnapshot,
|
|
1434
|
+
afterSnapshot,
|
|
1435
|
+
lineChanges: null,
|
|
1436
|
+
reasonCode: input.reasonCode ?? null,
|
|
1437
|
+
reasonText: input.reasonText ?? null,
|
|
1438
|
+
etfAmount: input.etfAmount != null ? String(input.etfAmount) : null,
|
|
1439
|
+
etfCurrency: input.etfCurrency ?? null,
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
reload.lastChangeLogId = log.id
|
|
1443
|
+
await this.em.flush()
|
|
1444
|
+
|
|
1445
|
+
return { changeLog: log, subscription: reload }
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1449
|
+
// Private helpers
|
|
1450
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1451
|
+
|
|
1452
|
+
private async recomputeSubscriptionAggregates(subscriptionId: string, scope: TenantScope) {
|
|
1453
|
+
const items = await this.em.find(CpqInventorySubscriptionItem, {
|
|
1454
|
+
subscriptionId,
|
|
1455
|
+
organizationId: scope.organizationId,
|
|
1456
|
+
tenantId: scope.tenantId,
|
|
1457
|
+
deletedAt: null,
|
|
1458
|
+
})
|
|
1459
|
+
|
|
1460
|
+
const mrcSum = items.reduce((s, i) => s + Number(i.mrcAmount), 0)
|
|
1461
|
+
const nrcSum = items.reduce((s, i) => s + Number(i.nrcAmount), 0)
|
|
1462
|
+
|
|
1463
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
1464
|
+
id: subscriptionId,
|
|
1465
|
+
organizationId: scope.organizationId,
|
|
1466
|
+
tenantId: scope.tenantId,
|
|
1467
|
+
})
|
|
1468
|
+
if (sub) {
|
|
1469
|
+
sub.mrcAmount = String(mrcSum)
|
|
1470
|
+
sub.nrcAmount = String(nrcSum)
|
|
1471
|
+
await this.em.flush()
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// ─── ARC private helpers (XD-250) ────────────────────────────────
|
|
1476
|
+
|
|
1477
|
+
private async requireSubscription(
|
|
1478
|
+
id: string,
|
|
1479
|
+
scope: TenantScope,
|
|
1480
|
+
): Promise<CpqInventorySubscription> {
|
|
1481
|
+
const sub = await this.em.findOne(CpqInventorySubscription, {
|
|
1482
|
+
id,
|
|
1483
|
+
organizationId: scope.organizationId,
|
|
1484
|
+
tenantId: scope.tenantId,
|
|
1485
|
+
deletedAt: null,
|
|
1486
|
+
})
|
|
1487
|
+
if (!sub) throw { status: 404, error: 'Subscription not found' }
|
|
1488
|
+
return sub
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
private async findIdempotentChangeLog(
|
|
1492
|
+
sourceOrderId: string | null | undefined,
|
|
1493
|
+
subscriptionId: string,
|
|
1494
|
+
scope: TenantScope,
|
|
1495
|
+
): Promise<CpqSubscriptionChangeLog | null> {
|
|
1496
|
+
if (!sourceOrderId) return null
|
|
1497
|
+
return this.em.findOne(CpqSubscriptionChangeLog, {
|
|
1498
|
+
organizationId: scope.organizationId,
|
|
1499
|
+
tenantId: scope.tenantId,
|
|
1500
|
+
deletedAt: null,
|
|
1501
|
+
sourceOrderId,
|
|
1502
|
+
subscriptionId,
|
|
1503
|
+
})
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
private async snapshotSubscription(
|
|
1507
|
+
sub: CpqInventorySubscription,
|
|
1508
|
+
scope: TenantScope,
|
|
1509
|
+
): Promise<Record<string, unknown>> {
|
|
1510
|
+
// `?? []` is defensive: in unit tests with a partially-mocked EM the
|
|
1511
|
+
// `find` call may return undefined. Mikro at runtime always returns an
|
|
1512
|
+
// array, so this only matters for harness code.
|
|
1513
|
+
const items = (await this.em.find(CpqInventorySubscriptionItem, {
|
|
1514
|
+
subscriptionId: sub.id,
|
|
1515
|
+
organizationId: scope.organizationId,
|
|
1516
|
+
tenantId: scope.tenantId,
|
|
1517
|
+
deletedAt: null,
|
|
1518
|
+
})) ?? []
|
|
1519
|
+
const assets = (await this.em.find(CpqInventoryAsset, {
|
|
1520
|
+
subscriptionId: sub.id,
|
|
1521
|
+
organizationId: scope.organizationId,
|
|
1522
|
+
tenantId: scope.tenantId,
|
|
1523
|
+
deletedAt: null,
|
|
1524
|
+
})) ?? []
|
|
1525
|
+
return {
|
|
1526
|
+
id: sub.id,
|
|
1527
|
+
code: sub.code,
|
|
1528
|
+
name: sub.name,
|
|
1529
|
+
status: sub.status,
|
|
1530
|
+
billingCycle: sub.billingCycle,
|
|
1531
|
+
currencyCode: sub.currencyCode,
|
|
1532
|
+
mrcAmount: sub.mrcAmount,
|
|
1533
|
+
nrcAmount: sub.nrcAmount,
|
|
1534
|
+
currentTermStart: toIsoOrNull(sub.currentTermStart),
|
|
1535
|
+
currentTermEnd: toIsoOrNull(sub.currentTermEnd),
|
|
1536
|
+
termMonths: sub.termMonths ?? null,
|
|
1537
|
+
version: sub.version,
|
|
1538
|
+
items: items.map((i) => ({
|
|
1539
|
+
id: i.id,
|
|
1540
|
+
productId: i.productId,
|
|
1541
|
+
offeringId: i.offeringId,
|
|
1542
|
+
specId: i.specId,
|
|
1543
|
+
name: i.name,
|
|
1544
|
+
status: i.status,
|
|
1545
|
+
configuration: i.configuration,
|
|
1546
|
+
mrcAmount: i.mrcAmount,
|
|
1547
|
+
nrcAmount: i.nrcAmount,
|
|
1548
|
+
currencyCode: i.currencyCode,
|
|
1549
|
+
quantity: i.quantity,
|
|
1550
|
+
sourceQuoteLineId: i.sourceQuoteLineId,
|
|
1551
|
+
})),
|
|
1552
|
+
assets: assets.map((a) => ({
|
|
1553
|
+
id: a.id,
|
|
1554
|
+
code: a.code,
|
|
1555
|
+
name: a.name,
|
|
1556
|
+
status: a.status,
|
|
1557
|
+
subscriptionItemId: a.subscriptionItemId,
|
|
1558
|
+
productId: a.productId,
|
|
1559
|
+
})),
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* Apply ARC line changes to a subscription's item set in place.
|
|
1565
|
+
* Returns a per-line summary ready to write into ChangeLog.line_changes.
|
|
1566
|
+
*/
|
|
1567
|
+
private async applyLineChanges(
|
|
1568
|
+
sub: CpqInventorySubscription,
|
|
1569
|
+
lineChanges: ArcLineChange[],
|
|
1570
|
+
scope: TenantScope,
|
|
1571
|
+
_ignored: null,
|
|
1572
|
+
): Promise<{ lineChangeSummary: Array<Record<string, unknown>> }> {
|
|
1573
|
+
void _ignored
|
|
1574
|
+
|
|
1575
|
+
const summary: Array<Record<string, unknown>> = []
|
|
1576
|
+
if (lineChanges.length === 0) return { lineChangeSummary: summary }
|
|
1577
|
+
|
|
1578
|
+
// Pre-load assetizable specs for new items to decide whether to materialize
|
|
1579
|
+
// assets on add lines.
|
|
1580
|
+
const specIdsForAdd = new Set<string>()
|
|
1581
|
+
for (const lc of lineChanges) {
|
|
1582
|
+
if (lc.action === 'add' && lc.itemInput?.specId) {
|
|
1583
|
+
specIdsForAdd.add(lc.itemInput.specId)
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
const assetizableSpecs = new Map<string, boolean>()
|
|
1587
|
+
if (specIdsForAdd.size > 0) {
|
|
1588
|
+
const specs = await this.em.find(CpqProductSpecification, {
|
|
1589
|
+
id: { $in: Array.from(specIdsForAdd) },
|
|
1590
|
+
organizationId: scope.organizationId,
|
|
1591
|
+
tenantId: scope.tenantId,
|
|
1592
|
+
deletedAt: null,
|
|
1593
|
+
})
|
|
1594
|
+
for (const s of specs) assetizableSpecs.set(s.id, s.isAssetizable === true)
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// XD-250 ARC: build quoteLineId → resulting subscriptionItemId map up-front
|
|
1598
|
+
// so `add` lines can resolve their parentItemId regardless of the order
|
|
1599
|
+
// their parent appears in `lineChanges`. For 'modify' the parent already
|
|
1600
|
+
// exists (its sourceSubscriptionItemId is the answer); for sibling 'add'
|
|
1601
|
+
// lines we pre-generate the new item id.
|
|
1602
|
+
const quoteLineToFinalItemId = new Map<string, string>()
|
|
1603
|
+
const preGeneratedAddIds = new Map<string, string>()
|
|
1604
|
+
for (const lc of lineChanges) {
|
|
1605
|
+
if (!lc.quoteLineId) continue
|
|
1606
|
+
if (lc.action === 'add') {
|
|
1607
|
+
const id = crypto.randomUUID()
|
|
1608
|
+
preGeneratedAddIds.set(lc.quoteLineId, id)
|
|
1609
|
+
quoteLineToFinalItemId.set(lc.quoteLineId, id)
|
|
1610
|
+
} else if (lc.action === 'modify' && lc.sourceSubscriptionItemId) {
|
|
1611
|
+
quoteLineToFinalItemId.set(lc.quoteLineId, lc.sourceSubscriptionItemId)
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
for (const lc of lineChanges) {
|
|
1616
|
+
if (lc.action === 'add') {
|
|
1617
|
+
if (!lc.itemInput) {
|
|
1618
|
+
throw { status: 400, error: 'add line is missing itemInput' }
|
|
1619
|
+
}
|
|
1620
|
+
// Pre-generated id — `defaultRaw: 'gen_random_uuid()'` only fires at
|
|
1621
|
+
// INSERT, so without this `item.id` is undefined between em.create()
|
|
1622
|
+
// and em.flush(). That breaks: (a) the asset row that references
|
|
1623
|
+
// `subscriptionItemId: item.id`, (b) the change-log `itemId` (which
|
|
1624
|
+
// the UI joins against the after-snapshot to resolve names), and
|
|
1625
|
+
// (c) the parentItemId resolution for sibling `add` children.
|
|
1626
|
+
const newItemId = lc.quoteLineId
|
|
1627
|
+
? preGeneratedAddIds.get(lc.quoteLineId) ?? crypto.randomUUID()
|
|
1628
|
+
: crypto.randomUUID()
|
|
1629
|
+
const parentItemId =
|
|
1630
|
+
lc.itemInput.parentItemId ??
|
|
1631
|
+
(lc.parentQuoteLineId
|
|
1632
|
+
? quoteLineToFinalItemId.get(lc.parentQuoteLineId) ?? null
|
|
1633
|
+
: null)
|
|
1634
|
+
const item = this.em.create(CpqInventorySubscriptionItem, {
|
|
1635
|
+
id: newItemId,
|
|
1636
|
+
organizationId: scope.organizationId,
|
|
1637
|
+
tenantId: scope.tenantId,
|
|
1638
|
+
subscriptionId: sub.id,
|
|
1639
|
+
customerId: sub.customerId,
|
|
1640
|
+
parentItemId,
|
|
1641
|
+
productId: lc.itemInput.productId ?? null,
|
|
1642
|
+
offeringId: lc.itemInput.offeringId ?? null,
|
|
1643
|
+
specId: lc.itemInput.specId ?? null,
|
|
1644
|
+
name: lc.itemInput.name,
|
|
1645
|
+
status: 'active',
|
|
1646
|
+
configuration: lc.itemInput.configuration ?? {},
|
|
1647
|
+
charges: lc.itemInput.charges ?? null,
|
|
1648
|
+
mrcAmount: String(lc.itemInput.mrcAmount ?? 0),
|
|
1649
|
+
nrcAmount: String(lc.itemInput.nrcAmount ?? 0),
|
|
1650
|
+
currencyCode: lc.itemInput.currencyCode ?? sub.currencyCode,
|
|
1651
|
+
quantity: lc.itemInput.quantity ?? 1,
|
|
1652
|
+
capacityTotal:
|
|
1653
|
+
lc.itemInput.capacityTotal != null ? String(lc.itemInput.capacityTotal) : null,
|
|
1654
|
+
capacityUsed: null,
|
|
1655
|
+
capacityUnit: lc.itemInput.capacityUnit ?? null,
|
|
1656
|
+
sourceQuoteLineId: lc.quoteLineId ?? lc.itemInput.sourceQuoteLineId ?? null,
|
|
1657
|
+
metadata: lc.itemInput.metadata ?? null,
|
|
1658
|
+
sortOrder: lc.itemInput.sortOrder ?? 0,
|
|
1659
|
+
})
|
|
1660
|
+
this.em.persist(item)
|
|
1661
|
+
|
|
1662
|
+
// Materialize a pending asset only when the spec is assetizable.
|
|
1663
|
+
if (lc.itemInput.specId && assetizableSpecs.get(lc.itemInput.specId)) {
|
|
1664
|
+
const code = await this.generateAssetCode(scope)
|
|
1665
|
+
const asset = this.em.create(CpqInventoryAsset, {
|
|
1666
|
+
organizationId: scope.organizationId,
|
|
1667
|
+
tenantId: scope.tenantId,
|
|
1668
|
+
customerId: sub.customerId,
|
|
1669
|
+
subscriptionId: sub.id,
|
|
1670
|
+
subscriptionItemId: item.id,
|
|
1671
|
+
productId: lc.itemInput.productId ?? null,
|
|
1672
|
+
offeringId: lc.itemInput.offeringId ?? null,
|
|
1673
|
+
specId: lc.itemInput.specId,
|
|
1674
|
+
code,
|
|
1675
|
+
name: lc.itemInput.name,
|
|
1676
|
+
status: 'pending',
|
|
1677
|
+
assetType: 'one_time',
|
|
1678
|
+
currencyCode: lc.itemInput.currencyCode ?? sub.currencyCode,
|
|
1679
|
+
purchasePrice: String(lc.itemInput.nrcAmount ?? 0),
|
|
1680
|
+
quantity: lc.itemInput.quantity ?? 1,
|
|
1681
|
+
configuration: lc.itemInput.configuration ?? {},
|
|
1682
|
+
charges: lc.itemInput.charges ?? null,
|
|
1683
|
+
sourceOrderId: null,
|
|
1684
|
+
sourceQuoteLineId: lc.quoteLineId ?? null,
|
|
1685
|
+
})
|
|
1686
|
+
this.em.persist(asset)
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
summary.push({
|
|
1690
|
+
action: 'add',
|
|
1691
|
+
itemId: item.id,
|
|
1692
|
+
quoteLineId: lc.quoteLineId ?? null,
|
|
1693
|
+
mrcAmount: Number(item.mrcAmount),
|
|
1694
|
+
nrcAmount: Number(item.nrcAmount),
|
|
1695
|
+
quantity: item.quantity,
|
|
1696
|
+
})
|
|
1697
|
+
} else if (lc.action === 'cancel') {
|
|
1698
|
+
if (!lc.sourceSubscriptionItemId) {
|
|
1699
|
+
throw { status: 400, error: 'cancel line is missing sourceSubscriptionItemId' }
|
|
1700
|
+
}
|
|
1701
|
+
const item = await this.em.findOne(CpqInventorySubscriptionItem, {
|
|
1702
|
+
id: lc.sourceSubscriptionItemId,
|
|
1703
|
+
subscriptionId: sub.id,
|
|
1704
|
+
organizationId: scope.organizationId,
|
|
1705
|
+
tenantId: scope.tenantId,
|
|
1706
|
+
deletedAt: null,
|
|
1707
|
+
})
|
|
1708
|
+
if (!item) {
|
|
1709
|
+
throw {
|
|
1710
|
+
status: 404,
|
|
1711
|
+
error: `Subscription item ${lc.sourceSubscriptionItemId} not found on subscription ${sub.id}`,
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
item.status = 'terminated'
|
|
1715
|
+
|
|
1716
|
+
// Cancel any active assets attached to this item.
|
|
1717
|
+
const assets = await this.em.find(CpqInventoryAsset, {
|
|
1718
|
+
subscriptionItemId: item.id,
|
|
1719
|
+
organizationId: scope.organizationId,
|
|
1720
|
+
tenantId: scope.tenantId,
|
|
1721
|
+
deletedAt: null,
|
|
1722
|
+
status: { $nin: ['returned', 'cancelled'] },
|
|
1723
|
+
})
|
|
1724
|
+
for (const asset of assets) asset.status = 'cancelled'
|
|
1725
|
+
|
|
1726
|
+
summary.push({
|
|
1727
|
+
action: 'cancel',
|
|
1728
|
+
itemId: item.id,
|
|
1729
|
+
quoteLineId: lc.quoteLineId ?? null,
|
|
1730
|
+
mrcDelta: -Number(item.mrcAmount),
|
|
1731
|
+
})
|
|
1732
|
+
} else if (lc.action === 'modify') {
|
|
1733
|
+
if (!lc.sourceSubscriptionItemId) {
|
|
1734
|
+
throw { status: 400, error: 'modify line is missing sourceSubscriptionItemId' }
|
|
1735
|
+
}
|
|
1736
|
+
const item = await this.em.findOne(CpqInventorySubscriptionItem, {
|
|
1737
|
+
id: lc.sourceSubscriptionItemId,
|
|
1738
|
+
subscriptionId: sub.id,
|
|
1739
|
+
organizationId: scope.organizationId,
|
|
1740
|
+
tenantId: scope.tenantId,
|
|
1741
|
+
deletedAt: null,
|
|
1742
|
+
})
|
|
1743
|
+
if (!item) {
|
|
1744
|
+
throw {
|
|
1745
|
+
status: 404,
|
|
1746
|
+
error: `Subscription item ${lc.sourceSubscriptionItemId} not found on subscription ${sub.id}`,
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
const oldMrc = Number(item.mrcAmount)
|
|
1750
|
+
if (lc.modifyPatch?.configuration !== undefined) {
|
|
1751
|
+
item.configuration = lc.modifyPatch.configuration
|
|
1752
|
+
}
|
|
1753
|
+
if (lc.modifyPatch?.charges !== undefined) {
|
|
1754
|
+
item.charges = lc.modifyPatch.charges
|
|
1755
|
+
}
|
|
1756
|
+
if (lc.modifyPatch?.mrcAmount !== undefined) {
|
|
1757
|
+
item.mrcAmount = String(lc.modifyPatch.mrcAmount)
|
|
1758
|
+
}
|
|
1759
|
+
if (lc.modifyPatch?.nrcAmount !== undefined) {
|
|
1760
|
+
item.nrcAmount = String(lc.modifyPatch.nrcAmount)
|
|
1761
|
+
}
|
|
1762
|
+
if (lc.modifyPatch?.quantity !== undefined) {
|
|
1763
|
+
item.quantity = lc.modifyPatch.quantity
|
|
1764
|
+
}
|
|
1765
|
+
const newMrc = Number(item.mrcAmount)
|
|
1766
|
+
if (newMrc !== oldMrc || lc.modifyPatch?.configuration || lc.modifyPatch?.charges) {
|
|
1767
|
+
summary.push({
|
|
1768
|
+
action: 'modify',
|
|
1769
|
+
itemId: item.id,
|
|
1770
|
+
quoteLineId: lc.quoteLineId ?? null,
|
|
1771
|
+
mrcDelta: newMrc - oldMrc,
|
|
1772
|
+
})
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
return { lineChangeSummary: summary }
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
private async recomputeSubscriptionAggregatesInPlace(
|
|
1781
|
+
sub: CpqInventorySubscription,
|
|
1782
|
+
scope: TenantScope,
|
|
1783
|
+
): Promise<void> {
|
|
1784
|
+
const items = await this.em.find(CpqInventorySubscriptionItem, {
|
|
1785
|
+
subscriptionId: sub.id,
|
|
1786
|
+
organizationId: scope.organizationId,
|
|
1787
|
+
tenantId: scope.tenantId,
|
|
1788
|
+
deletedAt: null,
|
|
1789
|
+
status: { $nin: ['terminated', 'expired', 'superseded'] },
|
|
1790
|
+
})
|
|
1791
|
+
const mrcSum = items.reduce((s, i) => s + Number(i.mrcAmount), 0)
|
|
1792
|
+
const nrcSum = items.reduce((s, i) => s + Number(i.nrcAmount), 0)
|
|
1793
|
+
sub.mrcAmount = String(mrcSum)
|
|
1794
|
+
sub.nrcAmount = String(nrcSum)
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
private createChangeLog(args: {
|
|
1798
|
+
subscription: CpqInventorySubscription
|
|
1799
|
+
changeType: ChangeLogType
|
|
1800
|
+
sourceQuoteId?: string | null
|
|
1801
|
+
sourceOrderId?: string | null
|
|
1802
|
+
performedByUserId?: string | null
|
|
1803
|
+
beforeSnapshot: Record<string, unknown> | null
|
|
1804
|
+
afterSnapshot: Record<string, unknown> | null
|
|
1805
|
+
lineChanges: Array<Record<string, unknown>> | null
|
|
1806
|
+
termChange?: Record<string, unknown> | null
|
|
1807
|
+
mergedIntoSubscriptionId?: string | null
|
|
1808
|
+
mergedFromSubscriptionIds?: string[] | null
|
|
1809
|
+
reasonCode?: string | null
|
|
1810
|
+
reasonText?: string | null
|
|
1811
|
+
etfAmount?: string | null
|
|
1812
|
+
etfCurrency?: string | null
|
|
1813
|
+
}): CpqSubscriptionChangeLog {
|
|
1814
|
+
const log = this.em.create(CpqSubscriptionChangeLog, {
|
|
1815
|
+
organizationId: args.subscription.organizationId,
|
|
1816
|
+
tenantId: args.subscription.tenantId,
|
|
1817
|
+
subscriptionId: args.subscription.id,
|
|
1818
|
+
changeType: args.changeType,
|
|
1819
|
+
sourceQuoteId: args.sourceQuoteId ?? null,
|
|
1820
|
+
sourceOrderId: args.sourceOrderId ?? null,
|
|
1821
|
+
performedByUserId: args.performedByUserId ?? null,
|
|
1822
|
+
effectiveAt: new Date(),
|
|
1823
|
+
beforeSnapshot: args.beforeSnapshot,
|
|
1824
|
+
afterSnapshot: args.afterSnapshot,
|
|
1825
|
+
lineChanges: args.lineChanges,
|
|
1826
|
+
termChange: args.termChange ?? null,
|
|
1827
|
+
mergedIntoSubscriptionId: args.mergedIntoSubscriptionId ?? null,
|
|
1828
|
+
mergedFromSubscriptionIds: args.mergedFromSubscriptionIds ?? null,
|
|
1829
|
+
reasonCode: args.reasonCode ?? null,
|
|
1830
|
+
reasonText: args.reasonText ?? null,
|
|
1831
|
+
etfAmount: args.etfAmount ?? null,
|
|
1832
|
+
etfCurrency: args.etfCurrency ?? null,
|
|
1833
|
+
})
|
|
1834
|
+
this.em.persist(log)
|
|
1835
|
+
return log
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
private buildItemTree(items: CpqInventorySubscriptionItem[]): SubscriptionItemResultNode[] {
|
|
1839
|
+
const nodeMap = new Map<string, SubscriptionItemResultNode>()
|
|
1840
|
+
|
|
1841
|
+
for (const item of items) {
|
|
1842
|
+
nodeMap.set(item.id, {
|
|
1843
|
+
id: item.id,
|
|
1844
|
+
subscriptionId: item.subscriptionId,
|
|
1845
|
+
parentItemId: item.parentItemId ?? null,
|
|
1846
|
+
productId: item.productId ?? null,
|
|
1847
|
+
offeringId: item.offeringId ?? null,
|
|
1848
|
+
specId: item.specId ?? null,
|
|
1849
|
+
name: item.name,
|
|
1850
|
+
status: item.status,
|
|
1851
|
+
configuration: item.configuration ?? {},
|
|
1852
|
+
charges: item.charges ?? null,
|
|
1853
|
+
mrcAmount: Number(item.mrcAmount),
|
|
1854
|
+
nrcAmount: Number(item.nrcAmount),
|
|
1855
|
+
currencyCode: item.currencyCode,
|
|
1856
|
+
quantity: item.quantity,
|
|
1857
|
+
capacityTotal: item.capacityTotal != null ? Number(item.capacityTotal) : null,
|
|
1858
|
+
capacityUsed: item.capacityUsed != null ? Number(item.capacityUsed) : null,
|
|
1859
|
+
capacityUnit: item.capacityUnit ?? null,
|
|
1860
|
+
metadata: item.metadata ?? null,
|
|
1861
|
+
sortOrder: item.sortOrder,
|
|
1862
|
+
children: [],
|
|
1863
|
+
})
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const roots: SubscriptionItemResultNode[] = []
|
|
1867
|
+
|
|
1868
|
+
for (const node of nodeMap.values()) {
|
|
1869
|
+
if (node.parentItemId && nodeMap.has(node.parentItemId)) {
|
|
1870
|
+
nodeMap.get(node.parentItemId)!.children.push(node)
|
|
1871
|
+
} else {
|
|
1872
|
+
roots.push(node)
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
return roots
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
private async generateSubscriptionCode(scope: TenantScope): Promise<string> {
|
|
1880
|
+
const year = new Date().getFullYear()
|
|
1881
|
+
const count = await this.em.count(CpqInventorySubscription, {
|
|
1882
|
+
organizationId: scope.organizationId,
|
|
1883
|
+
tenantId: scope.tenantId,
|
|
1884
|
+
})
|
|
1885
|
+
const seq = String(count + 1).padStart(5, '0')
|
|
1886
|
+
return `SUB-${year}-${seq}`
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
private async generateAssetCode(scope: TenantScope): Promise<string> {
|
|
1890
|
+
const year = new Date().getFullYear()
|
|
1891
|
+
const count = await this.em.count(CpqInventoryAsset, {
|
|
1892
|
+
organizationId: scope.organizationId,
|
|
1893
|
+
tenantId: scope.tenantId,
|
|
1894
|
+
})
|
|
1895
|
+
const seq = String(count + 1).padStart(5, '0')
|
|
1896
|
+
return `AST-${year}-${seq}`
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
private async subscriptionIdsForProduct(productId: string, scope: TenantScope): Promise<string[]> {
|
|
1900
|
+
const items = await this.em.find(
|
|
1901
|
+
CpqInventorySubscriptionItem,
|
|
1902
|
+
{
|
|
1903
|
+
organizationId: scope.organizationId,
|
|
1904
|
+
tenantId: scope.tenantId,
|
|
1905
|
+
deletedAt: null,
|
|
1906
|
+
productId,
|
|
1907
|
+
},
|
|
1908
|
+
{ fields: ['subscriptionId'] as const },
|
|
1909
|
+
)
|
|
1910
|
+
return [...new Set(items.map((i) => i.subscriptionId))]
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// ─── Module-private helpers ─────────────────────────────────────
|
|
1915
|
+
|
|
1916
|
+
function toDate(value: Date | string): Date {
|
|
1917
|
+
return value instanceof Date ? value : new Date(value)
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* MikroORM `@Property({ type: 'date' })` columns hydrate as strings on
|
|
1922
|
+
* Postgres (YYYY-MM-DD) — calling `.toISOString()` on them throws. This
|
|
1923
|
+
* helper accepts either form.
|
|
1924
|
+
*/
|
|
1925
|
+
function toIsoOrNull(value: Date | string | null | undefined): string | null {
|
|
1926
|
+
if (value == null) return null
|
|
1927
|
+
if (value instanceof Date) return value.toISOString()
|
|
1928
|
+
return String(value)
|
|
1929
|
+
}
|