@dainamite/cpq 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/MIGRATION.md +74 -0
- package/dist/modules/cpq/acl.js +1 -0
- package/dist/modules/cpq/acl.js.map +2 -2
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js +45 -0
- package/dist/modules/cpq/api/_helpers/adjustmentCommandRoute.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js +23 -0
- package/dist/modules/cpq/api/_helpers/quoteMutationGuard.js.map +7 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js +19 -0
- package/dist/modules/cpq/api/_helpers/quoteRouteOpenApi.js.map +7 -0
- package/dist/modules/cpq/api/orders/[orderId]/route.js +17 -1
- package/dist/modules/cpq/api/orders/[orderId]/route.js.map +2 -2
- package/dist/modules/cpq/api/orders/route.js +47 -2
- package/dist/modules/cpq/api/orders/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js +54 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js +42 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js +27 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/items/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js +30 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js +47 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.js.map +7 -0
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js +32 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/recalculate/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js +33 -2
- package/dist/modules/cpq/api/quotes/[quoteId]/status/route.js.map +2 -2
- package/dist/modules/cpq/api/quotes/price/route.js +43 -3
- package/dist/modules/cpq/api/quotes/price/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +22 -8
- package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +89 -232
- package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/orders/page.meta.js.map +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +205 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js +79 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js +107 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js +130 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.js.map +7 -0
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +144 -221
- package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +3 -3
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js +1 -1
- package/dist/modules/cpq/backend/cpq/quotes/page.meta.js.map +1 -1
- package/dist/modules/cpq/ce.js +8 -0
- package/dist/modules/cpq/ce.js.map +2 -2
- package/dist/modules/cpq/cli.js +34 -1
- package/dist/modules/cpq/cli.js.map +2 -2
- package/dist/modules/cpq/commands/price-adjustments.js +107 -0
- package/dist/modules/cpq/commands/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/components/ArcLineDiff.js +53 -0
- package/dist/modules/cpq/components/ArcLineDiff.js.map +7 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js +154 -0
- package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +7 -0
- package/dist/modules/cpq/components/PriceWaterfall.js +66 -0
- package/dist/modules/cpq/components/PriceWaterfall.js.map +7 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js +117 -0
- package/dist/modules/cpq/components/PricingSummaryCard.js.map +7 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js +27 -0
- package/dist/modules/cpq/components/useGrantedFeatures.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +97 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/price-adjustments.js +70 -0
- package/dist/modules/cpq/data/price-adjustments.js.map +7 -0
- package/dist/modules/cpq/data/validators.js +4 -0
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/di.js +6 -0
- package/dist/modules/cpq/di.js.map +2 -2
- package/dist/modules/cpq/events.js +47 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/index.js +1 -1
- package/dist/modules/cpq/index.js.map +2 -2
- package/dist/modules/cpq/lib/auditActor.js +10 -0
- package/dist/modules/cpq/lib/auditActor.js.map +7 -0
- package/dist/modules/cpq/lib/formatMoney.js +7 -0
- package/dist/modules/cpq/lib/formatMoney.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js +60 -0
- package/dist/modules/cpq/lib/priceAdjustmentDefaults.js.map +7 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js +169 -0
- package/dist/modules/cpq/lib/priceAdjustmentView.js.map +7 -0
- package/dist/modules/cpq/lib/quotePermissions.js +13 -0
- package/dist/modules/cpq/lib/quotePermissions.js.map +7 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js +28 -0
- package/dist/modules/cpq/lib/quoteSummaryView.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js +22 -0
- package/dist/modules/cpq/migrations/Migration20261110213052.js.map +7 -0
- package/dist/modules/cpq/services/arcLineRemoval.js +53 -0
- package/dist/modules/cpq/services/arcLineRemoval.js.map +7 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js +79 -0
- package/dist/modules/cpq/services/arcPriceFreeze.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +25 -28
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +7 -0
- package/dist/modules/cpq/services/cpqPricingService.js.map +2 -2
- package/dist/modules/cpq/services/cpqQuotingService.js +330 -109
- package/dist/modules/cpq/services/cpqQuotingService.js.map +2 -2
- package/dist/modules/cpq/services/orderPricingSummary.js +26 -0
- package/dist/modules/cpq/services/orderPricingSummary.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js +264 -0
- package/dist/modules/cpq/services/priceAdjustments/adjustmentService.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js +25 -0
- package/dist/modules/cpq/services/priceAdjustments/chargeWaterfall.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js +38 -0
- package/dist/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js +104 -0
- package/dist/modules/cpq/services/priceAdjustments/pricingSnapshot.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js +102 -0
- package/dist/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js +52 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingEvents.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js +37 -0
- package/dist/modules/cpq/services/priceAdjustments/quotePricingWrite.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js +107 -0
- package/dist/modules/cpq/services/priceAdjustments/resolveManualAdjustments.js.map +7 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js +30 -0
- package/dist/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.js.map +7 -0
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/dist/modules/cpq/setup.js +6 -0
- package/dist/modules/cpq/setup.js.map +2 -2
- package/package.json +1 -1
- package/src/modules/cpq/__integration__/TC-CPQ-ARC-012.spec.ts +134 -0
- package/src/modules/cpq/acl.ts +1 -0
- package/src/modules/cpq/api/__tests__/adjustmentRoutes.test.ts +79 -0
- package/src/modules/cpq/api/__tests__/quoteWriteGuards.test.ts +100 -0
- package/src/modules/cpq/api/_helpers/__tests__/quoteMutationGuard.test.ts +48 -0
- package/src/modules/cpq/api/_helpers/adjustmentCommandRoute.ts +45 -0
- package/src/modules/cpq/api/_helpers/quoteMutationGuard.ts +38 -0
- package/src/modules/cpq/api/_helpers/quoteRouteOpenApi.ts +19 -0
- package/src/modules/cpq/api/orders/[orderId]/route.ts +18 -0
- package/src/modules/cpq/api/orders/route.ts +51 -1
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/restore/route.ts +56 -0
- package/src/modules/cpq/api/quotes/[quoteId]/items/[lineId]/route.ts +48 -1
- package/src/modules/cpq/api/quotes/[quoteId]/items/route.ts +31 -1
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/[adjustmentId]/route.ts +39 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/preview/route.ts +24 -0
- package/src/modules/cpq/api/quotes/[quoteId]/price-adjustments/route.ts +40 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/__tests__/route.test.ts +95 -0
- package/src/modules/cpq/api/quotes/[quoteId]/recalculate/route.ts +40 -1
- package/src/modules/cpq/api/quotes/[quoteId]/status/__tests__/route.test.ts +74 -0
- package/src/modules/cpq/api/quotes/[quoteId]/status/route.ts +37 -1
- package/src/modules/cpq/api/quotes/price/__tests__/route.test.ts +37 -0
- package/src/modules/cpq/api/quotes/price/route.ts +28 -2
- package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +32 -10
- package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +60 -269
- package/src/modules/cpq/backend/cpq/orders/page.meta.ts +1 -1
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +260 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentsPanel.tsx +124 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/__tests__/PriceAdjustmentsPanel.test.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/arcLineRemoval.tsx +76 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/priceAdjustmentContext.tsx +133 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/_components/usePriceAdjustments.tsx +165 -0
- package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +174 -263
- package/src/modules/cpq/backend/cpq/quotes/page.meta.ts +1 -1
- package/src/modules/cpq/ce.ts +8 -0
- package/src/modules/cpq/cli.ts +48 -1
- package/src/modules/cpq/commands/__tests__/price-adjustments.test.ts +54 -0
- package/src/modules/cpq/commands/price-adjustments.ts +90 -0
- package/src/modules/cpq/components/ArcLineDiff.tsx +87 -0
- package/src/modules/cpq/components/ChargeBreakdownTable.tsx +201 -0
- package/src/modules/cpq/components/PriceWaterfall.tsx +94 -0
- package/src/modules/cpq/components/PricingSummaryCard.tsx +128 -0
- package/src/modules/cpq/components/__tests__/PriceWaterfall.test.tsx +38 -0
- package/src/modules/cpq/components/useGrantedFeatures.ts +35 -0
- package/src/modules/cpq/data/__tests__/price-adjustment-migration.test.ts +11 -0
- package/src/modules/cpq/data/__tests__/price-adjustments.test.ts +26 -0
- package/src/modules/cpq/data/entities.ts +83 -0
- package/src/modules/cpq/data/price-adjustments.ts +67 -0
- package/src/modules/cpq/data/validators.ts +3 -0
- package/src/modules/cpq/di.ts +6 -0
- package/src/modules/cpq/events.ts +53 -2
- package/src/modules/cpq/index.ts +1 -1
- package/src/modules/cpq/lib/__tests__/auditActor.test.ts +12 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentDefaults.test.ts +37 -0
- package/src/modules/cpq/lib/__tests__/priceAdjustmentView.test.ts +152 -0
- package/src/modules/cpq/lib/__tests__/quotePermissions.test.ts +30 -0
- package/src/modules/cpq/lib/__tests__/quoteSummaryView.test.ts +56 -0
- package/src/modules/cpq/lib/auditActor.ts +13 -0
- package/src/modules/cpq/lib/formatMoney.ts +4 -0
- package/src/modules/cpq/lib/priceAdjustmentDefaults.ts +36 -0
- package/src/modules/cpq/lib/priceAdjustmentView.ts +259 -0
- package/src/modules/cpq/lib/quotePermissions.ts +22 -0
- package/src/modules/cpq/lib/quoteSummaryView.ts +51 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +3298 -2423
- package/src/modules/cpq/migrations/Migration20261110213052.ts +22 -0
- package/src/modules/cpq/services/__tests__/_helpers/mockEm.ts +9 -2
- package/src/modules/cpq/services/__tests__/adjustmentService.test.ts +183 -0
- package/src/modules/cpq/services/__tests__/arcLineRemoval.test.ts +109 -0
- package/src/modules/cpq/services/__tests__/arcPriceFreeze.test.ts +141 -0
- package/src/modules/cpq/services/__tests__/chargeWaterfall.test.ts +28 -0
- package/src/modules/cpq/services/__tests__/cpqOrderService.test.ts +35 -2
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.arc.test.ts +237 -0
- package/src/modules/cpq/services/__tests__/cpqQuotingService.test.ts +58 -3
- package/src/modules/cpq/services/__tests__/orderPricingSummary.test.ts +50 -0
- package/src/modules/cpq/services/__tests__/pricingSnapshot.test.ts +62 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentLifecycle.test.ts +35 -0
- package/src/modules/cpq/services/__tests__/quoteAdjustmentPricing.test.ts +65 -0
- package/src/modules/cpq/services/__tests__/quoteCloneAdjustments.test.ts +45 -0
- package/src/modules/cpq/services/__tests__/quoteDetachTargetAdjustments.test.ts +54 -0
- package/src/modules/cpq/services/__tests__/quoteEventsService.test.ts +72 -0
- package/src/modules/cpq/services/__tests__/quotePricingEvents.test.ts +126 -0
- package/src/modules/cpq/services/__tests__/quotePricingIsolation.test.ts +52 -0
- package/src/modules/cpq/services/__tests__/quotePricingWrite.test.ts +74 -0
- package/src/modules/cpq/services/__tests__/resolveManualAdjustments.test.ts +90 -0
- package/src/modules/cpq/services/__tests__/storedAdjustmentFailure.test.ts +9 -0
- package/src/modules/cpq/services/arcLineRemoval.ts +87 -0
- package/src/modules/cpq/services/arcPriceFreeze.ts +165 -0
- package/src/modules/cpq/services/cpqOrderService.ts +22 -28
- package/src/modules/cpq/services/cpqPricingService.ts +8 -0
- package/src/modules/cpq/services/cpqQuotingService.ts +374 -125
- package/src/modules/cpq/services/orderPricingSummary.ts +47 -0
- package/src/modules/cpq/services/priceAdjustments/adjustmentService.ts +249 -0
- package/src/modules/cpq/services/priceAdjustments/chargeWaterfall.ts +28 -0
- package/src/modules/cpq/services/priceAdjustments/loadQuoteAdjustments.ts +25 -0
- package/src/modules/cpq/services/priceAdjustments/pricingSnapshot.ts +86 -0
- package/src/modules/cpq/services/priceAdjustments/quoteAdjustmentLifecycle.ts +68 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingEvents.ts +94 -0
- package/src/modules/cpq/services/priceAdjustments/quotePricingWrite.ts +51 -0
- package/src/modules/cpq/services/priceAdjustments/resolveManualAdjustments.ts +116 -0
- package/src/modules/cpq/services/priceAdjustments/storedAdjustmentFailure.ts +31 -0
- package/src/modules/cpq/services/types.ts +34 -1
- package/src/modules/cpq/setup.ts +7 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { computeWaterfallSummary } from './priceAdjustments/pricingSnapshot'
|
|
2
|
+
import { isArcMirrorLine } from './arcPriceFreeze'
|
|
3
|
+
import type { PricingWaterfallSummary, ResolvedCharge } from './types'
|
|
4
|
+
|
|
5
|
+
export type OrderSummaryLine = {
|
|
6
|
+
lineId: string
|
|
7
|
+
quantity: number
|
|
8
|
+
termMonths: number | null
|
|
9
|
+
configuration: Record<string, unknown> | null
|
|
10
|
+
charges: Array<Record<string, unknown>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The quote's list / standard / offer figures and deal value, minus quote-only fields. */
|
|
14
|
+
export type OrderWaterfallSummary = Omit<PricingWaterfallSummary, 'snapshotHash' | 'hasTimeBasedAdjustments'>
|
|
15
|
+
|
|
16
|
+
/** Order lines store the quote's charges as plain JSON; fill the fields the waterfall needs. */
|
|
17
|
+
function toResolvedCharge(charge: Record<string, unknown>): ResolvedCharge {
|
|
18
|
+
return {
|
|
19
|
+
...charge,
|
|
20
|
+
chargeCode: String(charge.chargeCode ?? ''),
|
|
21
|
+
chargeType: (charge.chargeType ?? 'mrc') as ResolvedCharge['chargeType'],
|
|
22
|
+
unitPrice: Number(charge.unitPrice ?? 0),
|
|
23
|
+
totalPrice: charge.totalPrice == null ? null : Number(charge.totalPrice),
|
|
24
|
+
} as ResolvedCharge
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The same pricing summary the source quote shows (list totals, discount
|
|
29
|
+
* depth, deal value), computed from the order's line charges, which are
|
|
30
|
+
* copied verbatim from the quote at conversion. ARC mirror lines are left out
|
|
31
|
+
* of deal value exactly as on the quote.
|
|
32
|
+
*/
|
|
33
|
+
export function summarizeOrderPricing(
|
|
34
|
+
lines: readonly OrderSummaryLine[],
|
|
35
|
+
quoteType: string,
|
|
36
|
+
currencyCode: string,
|
|
37
|
+
): OrderWaterfallSummary {
|
|
38
|
+
const summary = computeWaterfallSummary(lines.map((line) => ({
|
|
39
|
+
lineId: line.lineId,
|
|
40
|
+
quantity: line.quantity,
|
|
41
|
+
termMonths: line.termMonths,
|
|
42
|
+
includeInDeal: !isArcMirrorLine(line.configuration, quoteType),
|
|
43
|
+
charges: line.charges.map(toResolvedCharge),
|
|
44
|
+
})), [], currencyCode)
|
|
45
|
+
const { snapshotHash: _snapshotHash, hasTimeBasedAdjustments: _timeBased, ...orderSummary } = summary
|
|
46
|
+
return orderSummary
|
|
47
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { isArcMirrorLine } from '../arcPriceFreeze'
|
|
2
|
+
import { ADJUSTMENT_TOGGLE, REASON_DICTIONARY } from '../../data/price-adjustments'
|
|
3
|
+
export { ADJUSTMENT_TOGGLE, REASON_DICTIONARY } from '../../data/price-adjustments'
|
|
4
|
+
import { loadQuoteAdjustments } from './loadQuoteAdjustments'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
7
|
+
import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
8
|
+
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
9
|
+
import type { FeatureTogglesService } from '@open-mercato/core/modules/feature_toggles/lib/feature-flag-check'
|
|
10
|
+
import { Dictionary, DictionaryEntry } from '@open-mercato/core/modules/dictionaries/data/entities'
|
|
11
|
+
import { CpqOrderConfiguration, CpqQuoteConfiguration, CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
12
|
+
import { cpqPriceAdjustmentInputSchema, cpqPriceAdjustmentPatchSchema, type PriceAdjustmentInput, type PriceAdjustmentPatch } from '../../data/price-adjustments'
|
|
13
|
+
import { QuotingError, type DefaultCpqQuotingService } from '../cpqQuotingService'
|
|
14
|
+
import { TERMINAL_STATUSES, type CpqStatus, type QuoteResult, type TenantScope } from '../types'
|
|
15
|
+
import { withQuotePricingWrite } from './quotePricingWrite'
|
|
16
|
+
|
|
17
|
+
/** Quote statuses in which reps may still add / edit / delete manual adjustments. */
|
|
18
|
+
const ADJUSTMENT_MUTABLE_STATUSES: readonly CpqStatus[] = ['new', 'incomplete', 'ready']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type Dependencies = {
|
|
22
|
+
em: EntityManager
|
|
23
|
+
quotingService: DefaultCpqQuotingService
|
|
24
|
+
featureTogglesService: Pick<FeatureTogglesService, 'getBoolConfig'>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function adjustmentInput(row: CpqQuotePriceAdjustment): PriceAdjustmentInput {
|
|
28
|
+
return cpqPriceAdjustmentInputSchema.parse({ level: row.level, quoteLineId: row.quoteLineId,
|
|
29
|
+
chargeCode: row.chargeCode, chargeTypeFilter: row.chargeTypeFilter, adjustmentType: row.adjustmentType,
|
|
30
|
+
value: Number(row.value), currencyCode: row.currencyCode, reasonCode: row.reasonCode, reasonNote: row.reasonNote })
|
|
31
|
+
}
|
|
32
|
+
export function adjustmentSnapshot(row: CpqQuotePriceAdjustment) {
|
|
33
|
+
return { id: row.id, ...adjustmentInput(row), quoteConfigurationId: row.quoteConfigurationId,
|
|
34
|
+
quoteId: row.quoteId, tenantId: row.tenantId, organizationId: row.organizationId,
|
|
35
|
+
createdByUserId: row.createdByUserId, createdAt: row.createdAt.toISOString(),
|
|
36
|
+
updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null, isActive: row.isActive }
|
|
37
|
+
}
|
|
38
|
+
export type AdjustmentSnapshot = ReturnType<typeof adjustmentSnapshot>
|
|
39
|
+
|
|
40
|
+
type OverlapKey = { id: string; level: string; quoteLineId: string; chargeCode?: string | null; chargeTypeFilter?: string | null }
|
|
41
|
+
/** Same level and target; line filters overlap when either covers all charges or both match. */
|
|
42
|
+
export function adjustmentsOverlap(a: OverlapKey, b: OverlapKey): boolean {
|
|
43
|
+
if (a.id === b.id || a.level !== b.level || a.quoteLineId !== b.quoteLineId) return false
|
|
44
|
+
if (a.level === 'charge') return (a.chargeCode ?? null) === (b.chargeCode ?? null)
|
|
45
|
+
return !a.chargeTypeFilter || !b.chargeTypeFilter || a.chargeTypeFilter === b.chargeTypeFilter
|
|
46
|
+
}
|
|
47
|
+
export interface AdjustmentWriteResult {
|
|
48
|
+
adjustment: AdjustmentSnapshot
|
|
49
|
+
before: AdjustmentSnapshot | null
|
|
50
|
+
quote: QuoteResult
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class CpqAdjustmentService {
|
|
54
|
+
constructor(private readonly deps: Dependencies) {}
|
|
55
|
+
|
|
56
|
+
async enabled(tenantId: string): Promise<boolean> {
|
|
57
|
+
const flag = await this.deps.featureTogglesService.getBoolConfig(ADJUSTMENT_TOGGLE, tenantId)
|
|
58
|
+
return flag.ok && flag.value === true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async list(quoteId: string, scope: TenantScope, page: number, pageSize: number) {
|
|
62
|
+
const em = this.deps.em.fork({ clear: true, useContext: false })
|
|
63
|
+
const scoped = { ...scope, deletedAt: null }
|
|
64
|
+
const quote = await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, id: quoteId }, undefined, scope)
|
|
65
|
+
?? await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, quoteId }, undefined, scope)
|
|
66
|
+
if (!quote) throw new QuotingError(404, 'Quote not found')
|
|
67
|
+
const filter = { ...scoped, quoteConfigurationId: quote.id, isActive: true }
|
|
68
|
+
const [rows, total, enabled, reasons] = await Promise.all([
|
|
69
|
+
findWithDecryption(em, CpqQuotePriceAdjustment, filter, { limit: pageSize, offset: (page - 1) * pageSize, orderBy: { createdAt: 'asc', id: 'asc' } }, scope),
|
|
70
|
+
em.count(CpqQuotePriceAdjustment, filter),
|
|
71
|
+
this.enabled(scope.tenantId),
|
|
72
|
+
this.reasons(em, scope),
|
|
73
|
+
])
|
|
74
|
+
return { items: rows.map(adjustmentSnapshot), total, page, pageSize, totalPages: Math.ceil(total / pageSize), pricingRevision: quote.pricingRevision ?? 0, enabled, reasons }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reason options for the adjustment dialog. Served here so reps need only
|
|
79
|
+
* `cpq.quotes.view`, not `dictionaries.view`. An inactive dictionary yields
|
|
80
|
+
* no options, matching `requireReason`.
|
|
81
|
+
*/
|
|
82
|
+
async reasons(em: EntityManager, scope: TenantScope): Promise<Array<{ value: string; label: string }>> {
|
|
83
|
+
const dictionary = await findOneWithDecryption(em, Dictionary, { key: REASON_DICTIONARY, ...scope, deletedAt: null, isActive: true }, undefined, scope)
|
|
84
|
+
if (!dictionary) return []
|
|
85
|
+
const entries = await findWithDecryption(em, DictionaryEntry, { dictionary: dictionary.id, ...scope }, { orderBy: { position: 'asc', label: 'asc' } }, scope)
|
|
86
|
+
return entries.map((entry) => ({ value: entry.value, label: entry.label }))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async preview(quoteId: string, input: PriceAdjustmentInput, replaceAdjustmentId: string | undefined, scope: TenantScope) {
|
|
90
|
+
const parsed = cpqPriceAdjustmentInputSchema.parse(input)
|
|
91
|
+
await this.requireEnabled(scope)
|
|
92
|
+
const em = this.deps.em.fork({ clear: true, useContext: false })
|
|
93
|
+
const scoped = { ...scope, deletedAt: null }
|
|
94
|
+
const quote = await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, id: quoteId }, undefined, scope)
|
|
95
|
+
?? await findOneWithDecryption(em, CpqQuoteConfiguration, { ...scoped, quoteId }, undefined, scope)
|
|
96
|
+
if (!quote) throw new QuotingError(404, 'Quote not found')
|
|
97
|
+
if (parsed.expectedRevision !== undefined && parsed.expectedRevision !== (quote.pricingRevision ?? 0)) throw new QuotingError(409, 'STALE_REVISION')
|
|
98
|
+
await this.assertMutable(em, quote, scope)
|
|
99
|
+
await this.requireTarget(em, quote, parsed.quoteLineId, scope)
|
|
100
|
+
const stored = await loadQuoteAdjustments(em, quote.id, scope)
|
|
101
|
+
const replaced = stored.find((row) => row.id === replaceAdjustmentId)
|
|
102
|
+
if (replaceAdjustmentId && !replaced) throw new QuotingError(404, 'Adjustment not found')
|
|
103
|
+
if (replaced && (replaced.level !== parsed.level || replaced.quoteLineId !== parsed.quoteLineId || (replaced.chargeCode ?? null) !== (parsed.chargeCode ?? null))) throw new QuotingError(422, 'Adjustment target is immutable')
|
|
104
|
+
if (!replaced || replaced.reasonCode !== parsed.reasonCode) await this.requireReason(em, parsed.reasonCode, scope)
|
|
105
|
+
const draft = { ...parsed, id: replaceAdjustmentId ?? randomUUID() }
|
|
106
|
+
const adjustments = [...stored.filter((row) => row.id !== replaceAdjustmentId), draft]
|
|
107
|
+
const result = await this.deps.quotingService.recalculateLockedQuote(em, quote, scope, false, adjustments)
|
|
108
|
+
return { quote: result, errors: result.adjustmentErrors ?? [] }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async create(quoteId: string, input: PriceAdjustmentInput, actor: string, scope: TenantScope): Promise<AdjustmentWriteResult> {
|
|
112
|
+
const parsed = cpqPriceAdjustmentInputSchema.parse(input)
|
|
113
|
+
await this.requireEnabled(scope)
|
|
114
|
+
return this.write(quoteId, scope, parsed.expectedRevision, async (em, quote) => {
|
|
115
|
+
await this.assertMutable(em, quote, scope)
|
|
116
|
+
await this.requireReason(em, parsed.reasonCode, scope)
|
|
117
|
+
await this.requireTarget(em, quote, parsed.quoteLineId, scope)
|
|
118
|
+
const id = randomUUID()
|
|
119
|
+
await this.rejectOverlap(em, quote, { id, ...parsed }, scope)
|
|
120
|
+
const row = em.create(CpqQuotePriceAdjustment, { ...scope, id, quoteConfigurationId: quote.id,
|
|
121
|
+
quoteId: quote.quoteId, ...this.values(parsed), createdByUserId: actor })
|
|
122
|
+
return this.save(em, quote, row, null, scope, true)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async update(quoteId: string, id: string, patch: PriceAdjustmentPatch, actor: string, scope: TenantScope): Promise<AdjustmentWriteResult> {
|
|
127
|
+
const parsed = cpqPriceAdjustmentPatchSchema.parse(patch)
|
|
128
|
+
await this.requireEnabled(scope)
|
|
129
|
+
return this.write(quoteId, scope, parsed.expectedRevision, async (em, quote) => {
|
|
130
|
+
await this.assertMutable(em, quote, scope)
|
|
131
|
+
const row = await this.loadRow(em, quote.id, id, scope)
|
|
132
|
+
const before = adjustmentSnapshot(row)
|
|
133
|
+
await this.requireTarget(em, quote, row.quoteLineId, scope)
|
|
134
|
+
const merged = cpqPriceAdjustmentInputSchema.parse({ ...adjustmentInput(row), ...parsed })
|
|
135
|
+
if (merged.reasonCode !== row.reasonCode) await this.requireReason(em, merged.reasonCode, scope)
|
|
136
|
+
await this.rejectOverlap(em, quote, { id: row.id, ...merged }, scope)
|
|
137
|
+
em.assign(row, { ...this.values(merged), updatedByUserId: actor, updatedAt: new Date() })
|
|
138
|
+
return this.save(em, quote, row, before, scope, true)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async remove(quoteId: string, id: string, actor: string, scope: TenantScope, expectedRevision?: number): Promise<AdjustmentWriteResult> {
|
|
143
|
+
await this.requireEnabled(scope)
|
|
144
|
+
return this.write(quoteId, scope, expectedRevision, async (em, quote) => {
|
|
145
|
+
const row = await this.loadRow(em, quote.id, id, scope)
|
|
146
|
+
const preview = await this.deps.quotingService.recalculateLockedQuote(em, quote, scope, false)
|
|
147
|
+
const invalid = preview.adjustmentErrors?.some((error) => error.adjustmentId === id) ?? false
|
|
148
|
+
await this.assertMutable(em, quote, scope, invalid)
|
|
149
|
+
const before = adjustmentSnapshot(row)
|
|
150
|
+
row.deletedAt = new Date(); row.isActive = false; row.updatedByUserId = actor; row.updatedAt = new Date()
|
|
151
|
+
return this.save(em, quote, row, before, scope, false)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Restores an audited previous row under the same quote lock; never reopens a locked quote. */
|
|
156
|
+
async undo(quoteId: string, id: string, before: AdjustmentSnapshot | null, after: AdjustmentSnapshot, actor: string, scope: TenantScope): Promise<void> {
|
|
157
|
+
await this.requireEnabled(scope)
|
|
158
|
+
await this.write(quoteId, scope, undefined, async (em, quote) => {
|
|
159
|
+
await this.assertMutable(em, quote, scope)
|
|
160
|
+
for (const snapshot of [before, after]) {
|
|
161
|
+
if (snapshot && (snapshot.tenantId !== scope.tenantId || snapshot.organizationId !== scope.organizationId || snapshot.quoteConfigurationId !== quote.id || snapshot.id !== id)) {
|
|
162
|
+
throw new QuotingError(403, 'Adjustment undo scope mismatch')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const row = await this.loadRow(em, quote.id, id, scope, true)
|
|
166
|
+
if (snapshotKey(adjustmentSnapshot(row)) !== snapshotKey(after)) throw new QuotingError(409, 'Adjustment changed since this action')
|
|
167
|
+
const restored = before ? cpqPriceAdjustmentInputSchema.parse(this.inputFromSnapshot(before)) : null
|
|
168
|
+
// Restoring an active row must not create an overlap a rep added since.
|
|
169
|
+
if (restored && before?.isActive && !before.deletedAt) await this.rejectOverlap(em, quote, { id, ...restored }, scope)
|
|
170
|
+
const beforeUndo = adjustmentSnapshot(row)
|
|
171
|
+
if (before && restored) em.assign(row, { ...this.values(restored),
|
|
172
|
+
deletedAt: before.deletedAt ? new Date(before.deletedAt) : null, isActive: before.isActive, updatedByUserId: actor, updatedAt: new Date() })
|
|
173
|
+
else { row.deletedAt = new Date(); row.isActive = false; row.updatedByUserId = actor; row.updatedAt = new Date() }
|
|
174
|
+
await this.save(em, quote, row, beforeUndo, scope, false)
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private inputFromSnapshot(snapshot: AdjustmentSnapshot): PriceAdjustmentInput {
|
|
179
|
+
const { id, quoteConfigurationId, quoteId, tenantId, organizationId, createdByUserId, createdAt, updatedAt, deletedAt, isActive, ...input } = snapshot
|
|
180
|
+
return input
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private values(input: PriceAdjustmentInput) {
|
|
184
|
+
return { level: input.level, quoteLineId: input.quoteLineId, chargeCode: input.chargeCode ?? null,
|
|
185
|
+
chargeTypeFilter: input.chargeTypeFilter ?? null, adjustmentType: input.adjustmentType, value: String(input.value),
|
|
186
|
+
currencyCode: input.currencyCode ?? null, reasonCode: input.reasonCode, reasonNote: input.reasonNote ?? null }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private write<T>(quoteId: string, scope: TenantScope, revision: number | undefined, execute: (em: EntityManager, quote: CpqQuoteConfiguration) => Promise<T>) {
|
|
190
|
+
return withQuotePricingWrite(this.deps.em, quoteId, scope, execute, (status, message) => new QuotingError(status, message), revision, undefined, { pricingCause: 'adjustment' })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async save(em: EntityManager, quote: CpqQuoteConfiguration, row: CpqQuotePriceAdjustment, before: AdjustmentSnapshot | null, scope: TenantScope, rejectInvalid: boolean): Promise<AdjustmentWriteResult> {
|
|
194
|
+
let priced: QuoteResult | undefined
|
|
195
|
+
await withAtomicFlush(em, [
|
|
196
|
+
() => { em.persist(row) },
|
|
197
|
+
async () => { priced = await this.deps.quotingService.recalculateLockedQuote(em, quote, scope) },
|
|
198
|
+
])
|
|
199
|
+
if (!priced) throw new Error('Adjustment recalculation did not return a quote')
|
|
200
|
+
const invalid = priced.adjustmentErrors?.find((error) => error.adjustmentId === row.id)
|
|
201
|
+
if (rejectInvalid && invalid) throw new QuotingError(422, invalid.code, undefined, { code: invalid.code, adjustmentId: row.id })
|
|
202
|
+
return { adjustment: adjustmentSnapshot(row), before, quote: priced }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async requireEnabled(scope: TenantScope) {
|
|
206
|
+
if (!(await this.enabled(scope.tenantId))) throw new QuotingError(403, 'Manual price adjustments are disabled')
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async requireTarget(em: EntityManager, quote: CpqQuoteConfiguration, quoteLineId: string, scope: TenantScope) {
|
|
210
|
+
const line = await findOneWithDecryption(em, CpqQuoteLineConfiguration, { quoteConfigurationId: quote.id, quoteLineId, ...scope, deletedAt: null }, undefined, scope)
|
|
211
|
+
if (!line) throw new QuotingError(404, 'Quote line not found')
|
|
212
|
+
if (!(line.offeringId || line.productId) || isArcMirrorLine(line.configuration, quote.quoteType)) throw new QuotingError(422, 'LINE_NOT_ELIGIBLE')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Refuse overlaps against every stored row, valid or not: an overlap hidden
|
|
217
|
+
* behind a currently-invalid row would start applying once that row is
|
|
218
|
+
* deleted, changing the price of a submitted quote. Runs before any mutation.
|
|
219
|
+
*/
|
|
220
|
+
private async rejectOverlap(em: EntityManager, quote: CpqQuoteConfiguration, candidate: Parameters<typeof adjustmentsOverlap>[0], scope: TenantScope) {
|
|
221
|
+
const stored = await loadQuoteAdjustments(em, quote.id, scope)
|
|
222
|
+
if (stored.some((other) => adjustmentsOverlap(candidate, other))) {
|
|
223
|
+
throw new QuotingError(422, 'ADJUSTMENT_OVERLAP', undefined, { code: 'ADJUSTMENT_OVERLAP', adjustmentId: candidate.id })
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private async requireReason(em: EntityManager, code: string, scope: TenantScope) {
|
|
228
|
+
const dictionary = await findOneWithDecryption(em, Dictionary, { key: REASON_DICTIONARY, ...scope, deletedAt: null, isActive: true }, undefined, scope)
|
|
229
|
+
const entry = dictionary && await findOneWithDecryption(em, DictionaryEntry, { dictionary: dictionary.id, ...scope, value: code }, undefined, scope)
|
|
230
|
+
if (!entry) throw new QuotingError(422, 'REASON_NOT_FOUND')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async assertMutable(em: EntityManager, quote: CpqQuoteConfiguration, scope: TenantScope, invalidDelete = false) {
|
|
234
|
+
const order = await findOneWithDecryption(em, CpqOrderConfiguration, { sourceQuoteId: quote.id, ...scope, deletedAt: null }, undefined, scope)
|
|
235
|
+
const allowed = ADJUSTMENT_MUTABLE_STATUSES.includes(quote.cpqStatus as CpqStatus)
|
|
236
|
+
const invalidException = invalidDelete && !TERMINAL_STATUSES.includes(quote.cpqStatus as CpqStatus)
|
|
237
|
+
if (order || (!allowed && !invalidException)) throw new QuotingError(409, 'ADJUSTMENT_LOCKED')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async loadRow(em: EntityManager, quoteConfigurationId: string, id: string, scope: TenantScope, deleted = false) {
|
|
241
|
+
const row = await findOneWithDecryption(em, CpqQuotePriceAdjustment, { id, quoteConfigurationId, ...scope, ...(deleted ? {} : { deletedAt: null, isActive: true }) }, undefined, scope)
|
|
242
|
+
if (!row) throw new QuotingError(404, 'Adjustment not found')
|
|
243
|
+
return row
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function snapshotKey(snapshot: AdjustmentSnapshot): string {
|
|
248
|
+
return JSON.stringify(Object.entries(snapshot).sort(([left], [right]) => left.localeCompare(right)))
|
|
249
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ResolvedCharge } from '../types'
|
|
2
|
+
|
|
3
|
+
/** Normalize legacy snapshots without changing their offer, rules or tier breakdown. */
|
|
4
|
+
export function withChargeWaterfall(charge: ResolvedCharge): ResolvedCharge {
|
|
5
|
+
const adjustments = charge.adjustments ?? []
|
|
6
|
+
const rules = adjustments.filter((entry) => entry.origin !== 'manual')
|
|
7
|
+
const manual = adjustments.find((entry) => entry.origin === 'manual')
|
|
8
|
+
const listUnitPrice = charge.listUnitPrice ?? rules[0]?.unitPriceBefore ?? manual?.unitPriceBefore ?? charge.unitPrice
|
|
9
|
+
const standardUnitPrice = charge.standardUnitPrice ?? rules.at(-1)?.unitPriceAfter ?? manual?.unitPriceBefore ?? charge.unitPrice
|
|
10
|
+
// Keep the exact stored total when no recalculation is needed (tier averages
|
|
11
|
+
// may not multiply back to the byte-identical stored total).
|
|
12
|
+
const totalAt = (unit: number) => charge.totalPrice === null ? null
|
|
13
|
+
: unit === charge.unitPrice ? charge.totalPrice : unit * (charge.quantity ?? 1)
|
|
14
|
+
return {
|
|
15
|
+
...charge,
|
|
16
|
+
listUnitPrice,
|
|
17
|
+
listTotalPrice: charge.listTotalPrice !== undefined ? charge.listTotalPrice : totalAt(listUnitPrice),
|
|
18
|
+
standardUnitPrice,
|
|
19
|
+
standardTotalPrice: charge.standardTotalPrice !== undefined ? charge.standardTotalPrice : totalAt(standardUnitPrice),
|
|
20
|
+
...(charge.adjustments ? { adjustments: adjustments.map((entry) => ({ ...entry, origin: entry.origin ?? 'rule' })) } : {}),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Keep the public calculator's default response compatible and free of waterfall data. */
|
|
25
|
+
export function withoutChargeWaterfall(charge: ResolvedCharge): ResolvedCharge {
|
|
26
|
+
const { listUnitPrice, listTotalPrice, standardUnitPrice, standardTotalPrice, ...publicCharge } = charge
|
|
27
|
+
return publicCharge
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
3
|
+
import { CpqQuotePriceAdjustment } from '../../data/entities'
|
|
4
|
+
import { resolvedPriceAdjustmentSchema, type PriceAdjustment } from '../../data/price-adjustments'
|
|
5
|
+
import type { TenantScope } from '../types'
|
|
6
|
+
|
|
7
|
+
/** One aggregate load per recalculation; keyset batches avoid an unbounded query. */
|
|
8
|
+
export async function loadQuoteAdjustments(em: EntityManager, quoteConfigurationId: string, scope: TenantScope): Promise<PriceAdjustment[]> {
|
|
9
|
+
const result: PriceAdjustment[] = []
|
|
10
|
+
let after: string | undefined
|
|
11
|
+
for (;;) {
|
|
12
|
+
const rows = await findWithDecryption(em, CpqQuotePriceAdjustment, {
|
|
13
|
+
quoteConfigurationId, ...scope, deletedAt: null, isActive: true,
|
|
14
|
+
...(after ? { id: { $gt: after } } : {}),
|
|
15
|
+
}, { limit: 500, orderBy: { id: 'asc' } }, scope)
|
|
16
|
+
for (const row of rows) result.push(resolvedPriceAdjustmentSchema.parse({
|
|
17
|
+
id: row.id, level: row.level, quoteLineId: row.quoteLineId, chargeCode: row.chargeCode,
|
|
18
|
+
chargeTypeFilter: row.chargeTypeFilter, adjustmentType: row.adjustmentType, value: Number(row.value),
|
|
19
|
+
currencyCode: row.currencyCode, reasonCode: row.reasonCode, reasonNote: row.reasonNote,
|
|
20
|
+
fromMonth: row.fromMonth, toMonth: row.toMonth, windowKind: row.windowKind, createdByUserId: row.createdByUserId,
|
|
21
|
+
}))
|
|
22
|
+
if (rows.length < 500) return result
|
|
23
|
+
after = rows[rows.length - 1].id
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { PriceAdjustment } from '../../data/price-adjustments'
|
|
3
|
+
import type { PricingWaterfallSummary, ResolvedCharge } from '../types'
|
|
4
|
+
import { withChargeWaterfall } from './chargeWaterfall'
|
|
5
|
+
|
|
6
|
+
export interface SnapshotLine {
|
|
7
|
+
lineId: string
|
|
8
|
+
quantity: number
|
|
9
|
+
termMonths?: number | null
|
|
10
|
+
/** ARC mirrors are excluded from deal value until their negotiated re-rate is supported. */
|
|
11
|
+
includeInDeal?: boolean
|
|
12
|
+
charges: ResolvedCharge[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function canonical(value: unknown): string {
|
|
16
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
17
|
+
if (value !== null && typeof value === 'object') {
|
|
18
|
+
const object = value as Record<string, unknown>
|
|
19
|
+
return `{${Object.keys(object).sort().filter((key) => object[key] !== undefined).map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(',')}}`
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(value ?? null)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Signed fractions: a rule surcharge yields a negative discount depth. */
|
|
25
|
+
function depth(before: number, after: number): number | null {
|
|
26
|
+
return before === 0 ? null : 1 - after / before
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function computeWaterfallSummary(
|
|
30
|
+
lines: SnapshotLine[],
|
|
31
|
+
adjustments: PriceAdjustment[],
|
|
32
|
+
currencyCode: string,
|
|
33
|
+
): PricingWaterfallSummary {
|
|
34
|
+
let listNrcTotal = 0, listMrcTotal = 0, standardNrcTotal = 0, standardMrcTotal = 0
|
|
35
|
+
let ruleDiscountTotal = 0, manualDiscountTotal = 0
|
|
36
|
+
const dealValue = { list: 0, standard: 0, offer: 0 }
|
|
37
|
+
const lineDepths: number[] = []
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
let standardDeal = 0, offerDeal = 0
|
|
40
|
+
for (const input of line.charges) {
|
|
41
|
+
const charge = withChargeWaterfall(input)
|
|
42
|
+
if (charge.chargeType === 'usage') continue
|
|
43
|
+
const list = (charge.listTotalPrice ?? 0) * line.quantity
|
|
44
|
+
const standard = (charge.standardTotalPrice ?? 0) * line.quantity
|
|
45
|
+
const offer = (charge.totalPrice ?? 0) * line.quantity
|
|
46
|
+
if (charge.chargeType === 'nrc') { listNrcTotal += list; standardNrcTotal += standard }
|
|
47
|
+
else { listMrcTotal += list; standardMrcTotal += standard }
|
|
48
|
+
for (const entry of charge.adjustments ?? []) {
|
|
49
|
+
const discount = Math.max(0, -entry.delta) * (charge.quantity ?? 1) * line.quantity
|
|
50
|
+
if (entry.origin === 'manual') manualDiscountTotal += discount
|
|
51
|
+
else ruleDiscountTotal += discount
|
|
52
|
+
}
|
|
53
|
+
if (line.includeInDeal === false) continue
|
|
54
|
+
const months = charge.chargeType === 'nrc' ? 1 : line.termMonths ?? 12
|
|
55
|
+
dealValue.list += list * months
|
|
56
|
+
dealValue.standard += standard * months
|
|
57
|
+
dealValue.offer += offer * months
|
|
58
|
+
standardDeal += standard * months; offerDeal += offer * months
|
|
59
|
+
}
|
|
60
|
+
const manualDepth = depth(standardDeal, offerDeal)
|
|
61
|
+
if (manualDepth !== null) lineDepths.push(manualDepth)
|
|
62
|
+
}
|
|
63
|
+
const pricedLines = lines.map((line) => ({
|
|
64
|
+
lineId: line.lineId, quantity: line.quantity, termMonths: line.termMonths ?? 12, includeInDeal: line.includeInDeal !== false,
|
|
65
|
+
charges: line.charges.map((input) => {
|
|
66
|
+
const charge = withChargeWaterfall(input)
|
|
67
|
+
return { chargeCode: charge.chargeCode, chargeType: charge.chargeType, currencyCode: charge.currencyCode,
|
|
68
|
+
listUnitPrice: charge.listUnitPrice, listTotalPrice: charge.listTotalPrice,
|
|
69
|
+
standardUnitPrice: charge.standardUnitPrice, standardTotalPrice: charge.standardTotalPrice,
|
|
70
|
+
unitPrice: charge.unitPrice, totalPrice: charge.totalPrice }
|
|
71
|
+
}).sort((a, b) => a.chargeCode.localeCompare(b.chargeCode) || canonical(a).localeCompare(canonical(b))),
|
|
72
|
+
})).sort((a, b) => a.lineId.localeCompare(b.lineId))
|
|
73
|
+
const pricedAdjustments = adjustments.map((row) => ({
|
|
74
|
+
id: row.id, level: row.level, quoteLineId: row.quoteLineId, chargeCode: row.chargeCode ?? null,
|
|
75
|
+
chargeTypeFilter: row.chargeTypeFilter ?? null, adjustmentType: row.adjustmentType, value: row.value,
|
|
76
|
+
currencyCode: row.currencyCode ?? null, windowKind: row.windowKind ?? 'relative', fromMonth: row.fromMonth ?? 1, toMonth: row.toMonth ?? null,
|
|
77
|
+
})).sort((a, b) => a.id.localeCompare(b.id))
|
|
78
|
+
return {
|
|
79
|
+
listNrcTotal, listMrcTotal, standardNrcTotal, standardMrcTotal,
|
|
80
|
+
ruleDiscountTotal, manualDiscountTotal, dealValue,
|
|
81
|
+
discountDepth: { rule: depth(dealValue.list, dealValue.standard), manual: depth(dealValue.standard, dealValue.offer),
|
|
82
|
+
total: depth(dealValue.list, dealValue.offer), maxLineManual: lineDepths.length ? Math.max(...lineDepths) : null },
|
|
83
|
+
hasTimeBasedAdjustments: false,
|
|
84
|
+
snapshotHash: createHash('sha256').update(canonical({ currencyCode, lines: pricedLines, adjustments: pricedAdjustments })).digest('hex'),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
5
|
+
import { SalesQuoteLine } from '@open-mercato/core/modules/sales/data/entities'
|
|
6
|
+
import { CpqQuoteLineConfiguration, CpqQuotePriceAdjustment } from '../../data/entities'
|
|
7
|
+
import type { TenantScope } from '../types'
|
|
8
|
+
|
|
9
|
+
export async function cloneQuoteAdjustments(em: EntityManager, sourceId: string, target: { id: string; quoteId: string }, lineIds: Map<string, string>, scope: TenantScope): Promise<void> {
|
|
10
|
+
let after: string | undefined
|
|
11
|
+
for (;;) {
|
|
12
|
+
const rows = await findWithDecryption(em, CpqQuotePriceAdjustment, { quoteConfigurationId: sourceId, ...scope, deletedAt: null,
|
|
13
|
+
...(after ? { id: { $gt: after } } : {}) }, { limit: 500, orderBy: { id: 'asc' } }, scope)
|
|
14
|
+
for (const row of rows) {
|
|
15
|
+
em.persist(em.create(CpqQuotePriceAdjustment, { id: randomUUID(), ...scope, quoteConfigurationId: target.id, quoteId: target.quoteId,
|
|
16
|
+
// An orphaned invalid target remains invalid, without pointing back into the source quote.
|
|
17
|
+
quoteLineId: lineIds.get(row.quoteLineId) ?? randomUUID(), level: row.level, chargeCode: row.chargeCode,
|
|
18
|
+
chargeTypeFilter: row.chargeTypeFilter, adjustmentType: row.adjustmentType, value: row.value, currencyCode: row.currencyCode,
|
|
19
|
+
windowKind: row.windowKind, fromMonth: row.fromMonth, toMonth: row.toMonth,
|
|
20
|
+
reasonCode: row.reasonCode, reasonNote: row.reasonNote, createdByUserId: row.createdByUserId, isActive: row.isActive }))
|
|
21
|
+
}
|
|
22
|
+
if (rows.length < 500) return
|
|
23
|
+
after = rows[rows.length - 1].id
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Delete the complete bundle subtree and its adjustments before recalculation reads it. */
|
|
28
|
+
export async function removeQuoteLineTree(em: EntityManager, quoteConfigurationId: string, root: CpqQuoteLineConfiguration, scope: TenantScope): Promise<void> {
|
|
29
|
+
const children = new Map<string, CpqQuoteLineConfiguration[]>()
|
|
30
|
+
let after: string | undefined
|
|
31
|
+
for (;;) {
|
|
32
|
+
const rows = await findWithDecryption(em, CpqQuoteLineConfiguration, { quoteConfigurationId, ...scope, deletedAt: null,
|
|
33
|
+
...(after ? { id: { $gt: after } } : {}) }, { limit: 500, orderBy: { id: 'asc' } }, scope)
|
|
34
|
+
for (const row of rows) if (row.parentLineId) {
|
|
35
|
+
const siblings = children.get(row.parentLineId) ?? []
|
|
36
|
+
siblings.push(row); children.set(row.parentLineId, siblings)
|
|
37
|
+
}
|
|
38
|
+
if (rows.length < 500) break
|
|
39
|
+
after = rows[rows.length - 1].id
|
|
40
|
+
}
|
|
41
|
+
const selected: CpqQuoteLineConfiguration[] = [], seen = new Set<string>()
|
|
42
|
+
const queue = [root]
|
|
43
|
+
for (let index = 0; index < queue.length; index++) {
|
|
44
|
+
const line = queue[index]
|
|
45
|
+
if (seen.has(line.quoteLineId)) continue
|
|
46
|
+
seen.add(line.quoteLineId); selected.push(line); queue.push(...(children.get(line.quoteLineId) ?? []))
|
|
47
|
+
}
|
|
48
|
+
const sales: SalesQuoteLine[] = [], adjustments: CpqQuotePriceAdjustment[] = []
|
|
49
|
+
const ids = selected.map((line) => line.quoteLineId)
|
|
50
|
+
for (let index = 0; index < ids.length; index += 500) {
|
|
51
|
+
const lineIds = ids.slice(index, index + 500)
|
|
52
|
+
sales.push(...await findWithDecryption(em, SalesQuoteLine, { id: { $in: lineIds }, ...scope, deletedAt: null }, { limit: 500 }, scope))
|
|
53
|
+
let cursor: string | undefined
|
|
54
|
+
for (;;) {
|
|
55
|
+
const rows = await findWithDecryption(em, CpqQuotePriceAdjustment, { quoteConfigurationId, quoteLineId: { $in: lineIds }, ...scope, deletedAt: null,
|
|
56
|
+
...(cursor ? { id: { $gt: cursor } } : {}) }, { limit: 500, orderBy: { id: 'asc' } }, scope)
|
|
57
|
+
adjustments.push(...rows)
|
|
58
|
+
if (rows.length < 500) break
|
|
59
|
+
cursor = rows[rows.length - 1].id
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const now = new Date()
|
|
63
|
+
await withAtomicFlush(em, [() => {
|
|
64
|
+
for (const line of selected) line.deletedAt = now
|
|
65
|
+
for (const line of sales) line.deletedAt = now
|
|
66
|
+
for (const adjustment of adjustments) { adjustment.deletedAt = now; adjustment.isActive = false }
|
|
67
|
+
}])
|
|
68
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { emitCpqEvent } from '../../events'
|
|
2
|
+
import type { CpqQuoteConfiguration } from '../../data/entities'
|
|
3
|
+
import type { PricingWaterfallSummary } from '../types'
|
|
4
|
+
|
|
5
|
+
export type QuotePricingCause = 'adjustment' | 'line' | 'recalculate' | 'clone'
|
|
6
|
+
export type QuoteStatusCause = 'manual' | 'auto'
|
|
7
|
+
|
|
8
|
+
/** Describes the write so its committed effect can be named in the events. */
|
|
9
|
+
export interface QuoteWriteEvents {
|
|
10
|
+
pricingCause: QuotePricingCause
|
|
11
|
+
/** `manual` only for an operator-requested transition; recalculation-driven changes are `auto`. */
|
|
12
|
+
statusCause?: QuoteStatusCause
|
|
13
|
+
actorUserId?: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface QuoteEventState {
|
|
17
|
+
snapshotHash: string | null
|
|
18
|
+
cpqStatus: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SnapshotSource = Pick<CpqQuoteConfiguration, 'id' | 'cpqStatus' | 'currencyCode' | 'pricingRevision' | 'pricingSummary' | 'tenantId' | 'organizationId'>
|
|
22
|
+
& { quoteType?: string | null }
|
|
23
|
+
|
|
24
|
+
export interface QuotePricingSnapshot {
|
|
25
|
+
quoteId: string
|
|
26
|
+
quoteType: 'new' | 'amend' | 'renew' | 'cancel'
|
|
27
|
+
cpqStatus: string
|
|
28
|
+
currencyCode: string
|
|
29
|
+
pricingRevision: number
|
|
30
|
+
snapshotHash: string
|
|
31
|
+
dealValue: PricingWaterfallSummary['dealValue']
|
|
32
|
+
discountDepth: PricingWaterfallSummary['discountDepth']
|
|
33
|
+
hasTimeBasedAdjustments: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Emit = (eventId: string, payload: Record<string, unknown>) => Promise<unknown> | unknown
|
|
37
|
+
|
|
38
|
+
function storedHash(summary: Record<string, unknown> | null | undefined): string | null {
|
|
39
|
+
const hash = summary?.snapshotHash
|
|
40
|
+
return typeof hash === 'string' && hash.length > 0 ? hash : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function captureQuoteEventState(quote: Pick<CpqQuoteConfiguration, 'cpqStatus' | 'pricingSummary'>): QuoteEventState {
|
|
44
|
+
return { snapshotHash: storedHash(quote.pricingSummary), cpqStatus: quote.cpqStatus }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildQuotePricingSnapshot(quote: SnapshotSource): QuotePricingSnapshot {
|
|
48
|
+
const summary = (quote.pricingSummary ?? {}) as Partial<PricingWaterfallSummary>
|
|
49
|
+
const quoteType = quote.quoteType === 'amend' || quote.quoteType === 'renew' || quote.quoteType === 'cancel' ? quote.quoteType : 'new'
|
|
50
|
+
return {
|
|
51
|
+
quoteId: quote.id,
|
|
52
|
+
quoteType,
|
|
53
|
+
cpqStatus: quote.cpqStatus,
|
|
54
|
+
currencyCode: quote.currencyCode,
|
|
55
|
+
pricingRevision: quote.pricingRevision ?? 0,
|
|
56
|
+
snapshotHash: storedHash(quote.pricingSummary) ?? '',
|
|
57
|
+
dealValue: summary.dealValue ?? { list: 0, standard: 0, offer: 0 },
|
|
58
|
+
discountDepth: summary.discountDepth ?? { rule: null, manual: null, total: null, maxLineManual: null },
|
|
59
|
+
hasTimeBasedAdjustments: summary.hasTimeBasedAdjustments === true,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Emits the committed effect of one quote write. Call only after the
|
|
65
|
+
* transaction has committed. A null previous hash is a legacy quote's first
|
|
66
|
+
* baseline and never counts as a pricing change. Dispatch failures are logged
|
|
67
|
+
* and swallowed: the write stands and the stored hash stays authoritative.
|
|
68
|
+
*/
|
|
69
|
+
export async function emitQuoteWriteEvents(
|
|
70
|
+
before: QuoteEventState | null,
|
|
71
|
+
quote: SnapshotSource,
|
|
72
|
+
options: QuoteWriteEvents,
|
|
73
|
+
emit: Emit = emitCpqEvent as unknown as Emit,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const after = captureQuoteEventState(quote)
|
|
76
|
+
const base = { tenantId: quote.tenantId, organizationId: quote.organizationId, ...buildQuotePricingSnapshot(quote), occurredAt: new Date().toISOString() }
|
|
77
|
+
const pricingChanged = after.snapshotHash !== null
|
|
78
|
+
&& (before === null ? true : before.snapshotHash !== null && before.snapshotHash !== after.snapshotHash)
|
|
79
|
+
const pending: Array<[string, Record<string, unknown>]> = []
|
|
80
|
+
if (pricingChanged) pending.push(['cpq.quote.pricing_changed', { ...base, cause: options.pricingCause }])
|
|
81
|
+
if (before !== null && before.cpqStatus !== after.cpqStatus) {
|
|
82
|
+
pending.push(['cpq.quote.status_changed', {
|
|
83
|
+
...base, fromStatus: before.cpqStatus, toStatus: after.cpqStatus,
|
|
84
|
+
cause: options.statusCause ?? 'auto', actorUserId: options.actorUserId ?? null,
|
|
85
|
+
}])
|
|
86
|
+
}
|
|
87
|
+
for (const [eventId, payload] of pending) {
|
|
88
|
+
try {
|
|
89
|
+
await emit(eventId, payload)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`[cpq] failed to emit ${eventId} for quote ${quote.id}`, error)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
2
|
+
import { LockMode } from '@mikro-orm/core'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
5
|
+
import { CpqQuoteConfiguration } from '../../data/entities'
|
|
6
|
+
import type { TenantScope } from '../types'
|
|
7
|
+
import { captureQuoteEventState, emitQuoteWriteEvents, type QuoteEventState, type QuoteWriteEvents } from './quotePricingEvents'
|
|
8
|
+
|
|
9
|
+
/** Every pricing write and conversion locks the same scoped aggregate row. */
|
|
10
|
+
export async function withQuotePricingWrite<T>(
|
|
11
|
+
em: EntityManager,
|
|
12
|
+
quoteId: string,
|
|
13
|
+
scope: TenantScope,
|
|
14
|
+
execute: (transaction: EntityManager, quote: CpqQuoteConfiguration) => Promise<T>,
|
|
15
|
+
makeError: (status: number, message: string) => Error,
|
|
16
|
+
expectedRevision?: number,
|
|
17
|
+
validate?: (quote: CpqQuoteConfiguration) => void,
|
|
18
|
+
events: QuoteWriteEvents = { pricingCause: 'recalculate' },
|
|
19
|
+
): Promise<T> {
|
|
20
|
+
// Do not inherit managed objects/pending writes or propagate committed state
|
|
21
|
+
// back into the caller's identity map. The returned DTO is authoritative;
|
|
22
|
+
// callers that need fresh entities must reload them in their own context.
|
|
23
|
+
// The isolated fork opens its own connection. Called from inside another
|
|
24
|
+
// transaction it could not see that transaction's uncommitted quote, and it
|
|
25
|
+
// would block forever on a row lock that transaction holds — fail fast.
|
|
26
|
+
if (em.isInTransaction?.()) throw new Error('withQuotePricingWrite must not run inside an existing transaction')
|
|
27
|
+
const isolated = em.fork({ clear: true, useContext: false })
|
|
28
|
+
const committed: { quote?: CpqQuoteConfiguration; before?: QuoteEventState } = {}
|
|
29
|
+
const result = await isolated.transactional(async (transaction) => {
|
|
30
|
+
const scoped = { organizationId: scope.organizationId, tenantId: scope.tenantId, deletedAt: null }
|
|
31
|
+
const options = { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true }
|
|
32
|
+
const quote = await findOneWithDecryption(transaction, CpqQuoteConfiguration, { ...scoped, id: quoteId }, options, scope)
|
|
33
|
+
?? await findOneWithDecryption(transaction, CpqQuoteConfiguration, { ...scoped, quoteId }, options, scope)
|
|
34
|
+
if (!quote) throw makeError(404, 'Quote not found')
|
|
35
|
+
const revision = quote.pricingRevision ?? 0
|
|
36
|
+
if (expectedRevision !== undefined && expectedRevision !== revision) throw makeError(409, 'STALE_REVISION')
|
|
37
|
+
validate?.(quote)
|
|
38
|
+
committed.quote = quote
|
|
39
|
+
committed.before = captureQuoteEventState(quote)
|
|
40
|
+
// This scalar update and every later flush are inside the same DB transaction.
|
|
41
|
+
let written!: T
|
|
42
|
+
await withAtomicFlush(transaction, [
|
|
43
|
+
() => { quote.pricingRevision = revision + 1 },
|
|
44
|
+
async () => { written = await execute(transaction, quote) },
|
|
45
|
+
])
|
|
46
|
+
return written
|
|
47
|
+
}, { clear: true })
|
|
48
|
+
// `transactional` resolved, so the write is committed; a rolled-back write never reaches here.
|
|
49
|
+
if (committed.quote && committed.before) await emitQuoteWriteEvents(committed.before, committed.quote, events)
|
|
50
|
+
return result
|
|
51
|
+
}
|