@dainamite/cpq 0.2.2 → 0.4.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 +62 -0
- package/MIGRATION.md +34 -1
- package/README.md +17 -12
- package/dist/modules/cpq/api/product-attributes/route.js +28 -53
- package/dist/modules/cpq/api/product-attributes/route.js.map +2 -2
- package/dist/modules/cpq/api/product-charges/route.js +12 -2
- package/dist/modules/cpq/api/product-charges/route.js.map +2 -2
- package/dist/modules/cpq/api/product-offerings/route.js +2 -0
- package/dist/modules/cpq/api/product-offerings/route.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +128 -59
- package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +11 -11
- package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +55 -1
- package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
- package/dist/modules/cpq/cli.js +58 -4
- package/dist/modules/cpq/cli.js.map +3 -3
- package/dist/modules/cpq/data/charge-pricing.js +12 -0
- package/dist/modules/cpq/data/charge-pricing.js.map +7 -0
- package/dist/modules/cpq/data/entities.js +3 -0
- package/dist/modules/cpq/data/entities.js.map +2 -2
- package/dist/modules/cpq/data/validators.js +36 -15
- package/dist/modules/cpq/data/validators.js.map +2 -2
- package/dist/modules/cpq/events.js +7 -0
- package/dist/modules/cpq/events.js.map +2 -2
- package/dist/modules/cpq/lib/seeds/demo.js +103 -0
- package/dist/modules/cpq/lib/seeds/demo.js.map +7 -0
- package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js +28 -0
- package/dist/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.js.map +7 -0
- package/dist/modules/cpq/services/cpqOrderService.js +130 -76
- package/dist/modules/cpq/services/cpqOrderService.js.map +2 -2
- package/dist/modules/cpq/services/cpqPricingService.js +53 -29
- package/dist/modules/cpq/services/cpqPricingService.js.map +3 -3
- package/dist/modules/cpq/services/types.js.map +2 -2
- package/package.json +11 -9
- package/src/modules/cpq/__integration__/TC-CPQ-012.spec.ts +34 -4
- package/src/modules/cpq/__tests__/package-isolation.test.ts +8 -3
- package/src/modules/cpq/api/product-attributes/__tests__/serializeAttribute.test.ts +62 -0
- package/src/modules/cpq/api/product-attributes/route.ts +34 -52
- package/src/modules/cpq/api/product-charges/route.ts +23 -2
- package/src/modules/cpq/api/product-offerings/route.ts +2 -0
- package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +151 -62
- package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -22
- package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +73 -1
- package/src/modules/cpq/cli.ts +83 -4
- package/src/modules/cpq/data/__tests__/charge-pricing.test.ts +49 -0
- package/src/modules/cpq/data/__tests__/validators.test.ts +60 -5
- package/src/modules/cpq/data/charge-pricing.ts +48 -0
- package/src/modules/cpq/data/entities.ts +10 -0
- package/src/modules/cpq/data/validators.ts +56 -26
- package/src/modules/cpq/events.ts +11 -3
- package/src/modules/cpq/lib/seeds/__tests__/demo.test.ts +78 -0
- package/src/modules/cpq/lib/seeds/demo.ts +145 -0
- package/src/modules/cpq/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/cpq/migrations/Migration20260602000000_cpq_charge_model_split.ts +57 -0
- package/src/modules/cpq/services/__tests__/cpqPricingService.test.ts +172 -1
- package/src/modules/cpq/services/cpqOrderService.ts +214 -103
- package/src/modules/cpq/services/cpqPricingService.ts +82 -34
- package/src/modules/cpq/services/types.ts +4 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/cpq/services/types.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n\n// \u2500\u2500\u2500 Shared types for CPQ Quoting v2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface TenantScope {\n organizationId: string\n tenantId: string\n}\n\nconst DEFAULT_CURRENCY = 'USD'\n\n/**\n * Returns the base currency code for a given tenant scope.\n * Falls back to 'USD' if no base currency is found.\n */\nexport async function getBaseCurrencyCode(\n em: EntityManager,\n scope: TenantScope,\n): Promise<string> {\n try {\n const { Currency } = await import(\n '@open-mercato/core/modules/currencies/data/entities'\n )\n const base = await em.findOne(Currency, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isBase: true,\n deletedAt: null,\n })\n return base?.code ?? DEFAULT_CURRENCY\n } catch {\n return DEFAULT_CURRENCY\n }\n}\n\n// \u2500\u2500\u2500 Quote lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_STATUSES = [\n 'new',\n 'incomplete',\n 'ready',\n 'in_approval',\n 'pre_approved',\n 'approved',\n 'with_customer',\n 'accepted',\n 'rejected',\n 'cancelled',\n] as const\n\nexport type CpqStatus = (typeof CPQ_STATUSES)[number]\n\nexport const TERMINAL_STATUSES: CpqStatus[] = ['accepted', 'rejected', 'cancelled']\n\n// Per product decision: the CPQ status path is operator-driven and can\n// jump between any pair of statuses. Self-transitions are excluded\n// because they're no-ops. ARC-specific guards (`validateArcQuote`,\n// concurrent-ARC checks) still run inside `transitionStatus` and are\n// the real safety net \u2014 this table only enforces the \"no self loop\"\n// invariant.\nexport const ALLOWED_TRANSITIONS: Record<CpqStatus, CpqStatus[]> = Object.fromEntries(\n CPQ_STATUSES.map((from) => [from, CPQ_STATUSES.filter((to) => to !== from)]),\n) as Record<CpqStatus, CpqStatus[]>\n\n// \u2500\u2500\u2500 Service input/output types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateQuoteInput {\n customerId: string\n quoteId?: string // Link to existing SalesQuote instead of creating a new one\n dealId?: string\n currencyCode?: string\n quoteContext?: Record<string, string | number | boolean>\n}\n\nexport interface QuoteItemInput {\n offeringId?: string\n productId?: string\n configuration: Record<string, unknown>\n quantity?: number\n usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>\n action?: 'add' | 'modify' | 'cancel'\n parentLineId?: string\n startDate?: string\n termMonths?: number\n endDate?: string\n /** XD-250 ARC: target subscription this line acts on. Auto-inherited from\n * the quote's single attached target when omitted. Required when the ARC\n * quote has multiple non-merge targets (operator must pick). */\n targetSubscriptionId?: string | null\n}\n\nexport interface ValidationError {\n type?: 'attribute' | 'relationship'\n attribute?: string\n relationshipType?: string\n sourceSpec?: string\n targetSpec?: string\n message: string\n}\n\n/** XD-250 ARC: snapshot of the source subscription item's *current* state.\n * Surfaced on quote / order lines so the operator can compare \"before\" (this\n * snapshot) vs \"after\" (the line's current configuration). Null for lines\n * that aren't ARC-mirrored (e.g. a fresh `add` line on an amend quote). */\nexport interface ArcLineSource {\n subscriptionItemId: string\n name: string\n mrcAmount: number\n nrcAmount: number\n quantity: number\n}\n\nexport interface QuoteLineResult {\n lineId: string\n offeringId: string | null\n offeringName: string\n offeringType: string | null\n productId: string | null\n action: string\n parentLineId: string | null\n quantity: number\n configuration: Record<string, unknown>\n startDate: string | null\n termMonths: number | null\n endDate: string | null\n nrcTotal: number\n mrcTotal: number\n usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null\n usageTotalEstimated: number | null\n charges: ResolvedCharge[]\n isConfigured: boolean\n validationErrors: ValidationError[] | null\n arcSource: ArcLineSource | null\n /** XD-250 multi-target ARC: which target subscription this line acts on. */\n targetSubscriptionId: string | null\n}\n\nexport interface PricingSummary {\n nrcTotal: number\n mrcTotal: number\n usageCharges: Array<{\n chargeCode: string\n chargeName: string\n unitPrice: number\n note: string | null\n }>\n usageTotalEstimated: number | null\n discountTotal: number\n surchargeTotal: number\n currencyCode: string\n}\n\nexport interface QuoteResult {\n id: string\n quoteId: string\n quoteNumber: string\n customerId: string\n /** Resolved customer display name (CustomerEntity.displayName). */\n customerName: string | null\n cpqStatus: string\n version: number\n parentQuoteId: string | null\n currencyCode: string\n /**\n * Set when this quote has been converted to a CPQ order. UI uses this\n * to lock editing and surface a \"Go to order\" entry point instead of\n * the Convert button. Backed by `CpqOrderConfiguration.sourceQuoteId`.\n */\n convertedOrderId: string | null\n validationResult: {\n valid: boolean\n errors: ValidationError[]\n }\n pricingSummary: PricingSummary\n lines: QuoteLineResult[]\n}\n\n// \u2500\u2500\u2500 Resolved charge (extended for v2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RuleAdjustment {\n ruleCode: string\n ruleName: string\n ruleType: string\n value: number\n unitPriceBefore: number\n unitPriceAfter: number\n delta: number\n}\n\nexport interface TierBreakdown {\n tiers: Array<{\n tier: number\n from: number\n to: number\n pricePerUnit: number\n quantity: number\n subtotal: number\n }>\n}\n\nexport interface ResolvedCharge {\n chargeCode: string\n chargeName: string\n chargeType: 'nrc' | 'mrc' | 'usage'\n pricingMethod: 'flat' | 'tiered' | 'per_unit'\n unitPrice: number\n quantity: number | null\n totalPrice: number | null\n currencyCode: string\n breakdown?: TierBreakdown | null\n note?: string | null\n adjustments?: RuleAdjustment[] | null\n}\n\n// \u2500\u2500\u2500 Inventory status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const INVENTORY_SUBSCRIPTION_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionStatus = (typeof INVENTORY_SUBSCRIPTION_STATUSES)[number]\n\nexport const INVENTORY_SUBSCRIPTION_TRANSITIONS: Record<InventorySubscriptionStatus, InventorySubscriptionStatus[]> = {\n pending: ['active', 'terminated'],\n active: ['suspended', 'terminated', 'expired', 'superseded'],\n suspended: ['active', 'terminated', 'superseded'],\n terminated: [],\n expired: [],\n superseded: [],\n}\n\nexport const INVENTORY_SUBSCRIPTION_ITEM_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionItemStatus = (typeof INVENTORY_SUBSCRIPTION_ITEM_STATUSES)[number]\n\nexport const INVENTORY_ASSET_STATUSES = ['pending', 'delivered', 'active', 'returned', 'cancelled'] as const\nexport type InventoryAssetStatus = (typeof INVENTORY_ASSET_STATUSES)[number]\n\nexport const INVENTORY_ASSET_TRANSITIONS: Record<InventoryAssetStatus, InventoryAssetStatus[]> = {\n pending: ['delivered', 'active', 'cancelled'],\n delivered: ['active', 'returned'],\n active: ['returned'],\n returned: [],\n cancelled: [],\n}\n\n// \u2500\u2500\u2500 Order status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_ORDER_STATUSES = ['draft', 'pending_activation', 'active', 'cancelled', 'fulfilled'] as const\nexport type CpqOrderStatus = (typeof CPQ_ORDER_STATUSES)[number]\n\nexport const CPQ_ORDER_TRANSITIONS: Record<CpqOrderStatus, CpqOrderStatus[]> = {\n draft: ['pending_activation', 'active', 'cancelled'],\n pending_activation: ['active', 'cancelled'],\n active: ['fulfilled', 'cancelled'],\n cancelled: [],\n fulfilled: [],\n}\n\n// \u2500\u2500\u2500 ARC (Amend / Renew / Cancel) constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_QUOTE_TYPES = ['new', 'amend', 'renew', 'cancel'] as const\nexport type CpqQuoteType = (typeof CPQ_QUOTE_TYPES)[number]\n\nexport const ARC_QUOTE_TYPES: ReadonlyArray<Exclude<CpqQuoteType, 'new'>> = ['amend', 'renew', 'cancel']\n\nexport const CHANGE_LOG_TYPES = [\n 'created',\n 'amend',\n 'renew',\n 'cancel',\n 'merge-result',\n 'merge-source',\n] as const\nexport type ChangeLogType = (typeof CHANGE_LOG_TYPES)[number]\n\nexport const MERGE_ACTIONS = ['standalone', 'absorb'] as const\nexport type MergeAction = (typeof MERGE_ACTIONS)[number]\n\nexport const ARC_REASON_CODES = [\n 'upgrade',\n 'downgrade',\n 'config-change',\n 'price-adjustment',\n 'term-extension',\n 'term-reduction',\n 'consolidation',\n 'customer-request',\n 'non-payment',\n 'contract-breach',\n 'other',\n] as const\nexport type ArcReasonCode = (typeof ARC_REASON_CODES)[number]\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\n// Charge model / pricing source live in the data layer (no ORM/DI coupling).\nimport type { ChargeModel } from '../data/charge-pricing'\n\n// \u2500\u2500\u2500 Shared types for CPQ Quoting v2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface TenantScope {\n organizationId: string\n tenantId: string\n}\n\nconst DEFAULT_CURRENCY = 'USD'\n\n/**\n * Returns the base currency code for a given tenant scope.\n * Falls back to 'USD' if no base currency is found.\n */\nexport async function getBaseCurrencyCode(\n em: EntityManager,\n scope: TenantScope,\n): Promise<string> {\n try {\n const { Currency } = await import(\n '@open-mercato/core/modules/currencies/data/entities'\n )\n const base = await em.findOne(Currency, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n isBase: true,\n deletedAt: null,\n })\n return base?.code ?? DEFAULT_CURRENCY\n } catch {\n return DEFAULT_CURRENCY\n }\n}\n\n// \u2500\u2500\u2500 Quote lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_STATUSES = [\n 'new',\n 'incomplete',\n 'ready',\n 'in_approval',\n 'pre_approved',\n 'approved',\n 'with_customer',\n 'accepted',\n 'rejected',\n 'cancelled',\n] as const\n\nexport type CpqStatus = (typeof CPQ_STATUSES)[number]\n\nexport const TERMINAL_STATUSES: CpqStatus[] = ['accepted', 'rejected', 'cancelled']\n\n// Per product decision: the CPQ status path is operator-driven and can\n// jump between any pair of statuses. Self-transitions are excluded\n// because they're no-ops. ARC-specific guards (`validateArcQuote`,\n// concurrent-ARC checks) still run inside `transitionStatus` and are\n// the real safety net \u2014 this table only enforces the \"no self loop\"\n// invariant.\nexport const ALLOWED_TRANSITIONS: Record<CpqStatus, CpqStatus[]> = Object.fromEntries(\n CPQ_STATUSES.map((from) => [from, CPQ_STATUSES.filter((to) => to !== from)]),\n) as Record<CpqStatus, CpqStatus[]>\n\n// \u2500\u2500\u2500 Service input/output types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateQuoteInput {\n customerId: string\n quoteId?: string // Link to existing SalesQuote instead of creating a new one\n dealId?: string\n currencyCode?: string\n quoteContext?: Record<string, string | number | boolean>\n}\n\nexport interface QuoteItemInput {\n offeringId?: string\n productId?: string\n configuration: Record<string, unknown>\n quantity?: number\n usageEstimates?: Array<{ chargeCode: string; estimatedQuantity: number }>\n action?: 'add' | 'modify' | 'cancel'\n parentLineId?: string\n startDate?: string\n termMonths?: number\n endDate?: string\n /** XD-250 ARC: target subscription this line acts on. Auto-inherited from\n * the quote's single attached target when omitted. Required when the ARC\n * quote has multiple non-merge targets (operator must pick). */\n targetSubscriptionId?: string | null\n}\n\nexport interface ValidationError {\n type?: 'attribute' | 'relationship'\n attribute?: string\n relationshipType?: string\n sourceSpec?: string\n targetSpec?: string\n message: string\n}\n\n/** XD-250 ARC: snapshot of the source subscription item's *current* state.\n * Surfaced on quote / order lines so the operator can compare \"before\" (this\n * snapshot) vs \"after\" (the line's current configuration). Null for lines\n * that aren't ARC-mirrored (e.g. a fresh `add` line on an amend quote). */\nexport interface ArcLineSource {\n subscriptionItemId: string\n name: string\n mrcAmount: number\n nrcAmount: number\n quantity: number\n}\n\nexport interface QuoteLineResult {\n lineId: string\n offeringId: string | null\n offeringName: string\n offeringType: string | null\n productId: string | null\n action: string\n parentLineId: string | null\n quantity: number\n configuration: Record<string, unknown>\n startDate: string | null\n termMonths: number | null\n endDate: string | null\n nrcTotal: number\n mrcTotal: number\n usageEstimates: Array<{ chargeCode: string; estimatedQuantity: number; estimatedTotal: number }> | null\n usageTotalEstimated: number | null\n charges: ResolvedCharge[]\n isConfigured: boolean\n validationErrors: ValidationError[] | null\n arcSource: ArcLineSource | null\n /** XD-250 multi-target ARC: which target subscription this line acts on. */\n targetSubscriptionId: string | null\n}\n\nexport interface PricingSummary {\n nrcTotal: number\n mrcTotal: number\n usageCharges: Array<{\n chargeCode: string\n chargeName: string\n unitPrice: number\n note: string | null\n }>\n usageTotalEstimated: number | null\n discountTotal: number\n surchargeTotal: number\n currencyCode: string\n}\n\nexport interface QuoteResult {\n id: string\n quoteId: string\n quoteNumber: string\n customerId: string\n /** Resolved customer display name (CustomerEntity.displayName). */\n customerName: string | null\n cpqStatus: string\n version: number\n parentQuoteId: string | null\n currencyCode: string\n /**\n * Set when this quote has been converted to a CPQ order. UI uses this\n * to lock editing and surface a \"Go to order\" entry point instead of\n * the Convert button. Backed by `CpqOrderConfiguration.sourceQuoteId`.\n */\n convertedOrderId: string | null\n validationResult: {\n valid: boolean\n errors: ValidationError[]\n }\n pricingSummary: PricingSummary\n lines: QuoteLineResult[]\n}\n\n// \u2500\u2500\u2500 Resolved charge (extended for v2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RuleAdjustment {\n ruleCode: string\n ruleName: string\n ruleType: string\n value: number\n unitPriceBefore: number\n unitPriceAfter: number\n delta: number\n}\n\nexport interface TierBreakdown {\n tiers: Array<{\n tier: number\n from: number\n to: number\n pricePerUnit: number\n quantity: number\n subtotal: number\n }>\n}\n\nexport interface ResolvedCharge {\n chargeCode: string\n chargeName: string\n chargeType: 'nrc' | 'mrc' | 'usage'\n // Display axis \u2014 the charge model (flat | per_unit | volume | tiered).\n pricingMethod: ChargeModel\n unitPrice: number\n quantity: number | null\n totalPrice: number | null\n currencyCode: string\n breakdown?: TierBreakdown | null\n note?: string | null\n adjustments?: RuleAdjustment[] | null\n}\n\n// \u2500\u2500\u2500 Inventory status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const INVENTORY_SUBSCRIPTION_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionStatus = (typeof INVENTORY_SUBSCRIPTION_STATUSES)[number]\n\nexport const INVENTORY_SUBSCRIPTION_TRANSITIONS: Record<InventorySubscriptionStatus, InventorySubscriptionStatus[]> = {\n pending: ['active', 'terminated'],\n active: ['suspended', 'terminated', 'expired', 'superseded'],\n suspended: ['active', 'terminated', 'superseded'],\n terminated: [],\n expired: [],\n superseded: [],\n}\n\nexport const INVENTORY_SUBSCRIPTION_ITEM_STATUSES = [\n 'pending',\n 'active',\n 'suspended',\n 'terminated',\n 'expired',\n 'superseded',\n] as const\nexport type InventorySubscriptionItemStatus = (typeof INVENTORY_SUBSCRIPTION_ITEM_STATUSES)[number]\n\nexport const INVENTORY_ASSET_STATUSES = ['pending', 'delivered', 'active', 'returned', 'cancelled'] as const\nexport type InventoryAssetStatus = (typeof INVENTORY_ASSET_STATUSES)[number]\n\nexport const INVENTORY_ASSET_TRANSITIONS: Record<InventoryAssetStatus, InventoryAssetStatus[]> = {\n pending: ['delivered', 'active', 'cancelled'],\n delivered: ['active', 'returned'],\n active: ['returned'],\n returned: [],\n cancelled: [],\n}\n\n// \u2500\u2500\u2500 Order status constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_ORDER_STATUSES = ['draft', 'pending_activation', 'active', 'cancelled', 'fulfilled'] as const\nexport type CpqOrderStatus = (typeof CPQ_ORDER_STATUSES)[number]\n\nexport const CPQ_ORDER_TRANSITIONS: Record<CpqOrderStatus, CpqOrderStatus[]> = {\n draft: ['pending_activation', 'active', 'cancelled'],\n pending_activation: ['active', 'cancelled'],\n active: ['fulfilled', 'cancelled'],\n cancelled: [],\n fulfilled: [],\n}\n\n// \u2500\u2500\u2500 ARC (Amend / Renew / Cancel) constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const CPQ_QUOTE_TYPES = ['new', 'amend', 'renew', 'cancel'] as const\nexport type CpqQuoteType = (typeof CPQ_QUOTE_TYPES)[number]\n\nexport const ARC_QUOTE_TYPES: ReadonlyArray<Exclude<CpqQuoteType, 'new'>> = ['amend', 'renew', 'cancel']\n\nexport const CHANGE_LOG_TYPES = [\n 'created',\n 'amend',\n 'renew',\n 'cancel',\n 'merge-result',\n 'merge-source',\n] as const\nexport type ChangeLogType = (typeof CHANGE_LOG_TYPES)[number]\n\nexport const MERGE_ACTIONS = ['standalone', 'absorb'] as const\nexport type MergeAction = (typeof MERGE_ACTIONS)[number]\n\nexport const ARC_REASON_CODES = [\n 'upgrade',\n 'downgrade',\n 'config-change',\n 'price-adjustment',\n 'term-extension',\n 'term-reduction',\n 'consolidation',\n 'customer-request',\n 'non-payment',\n 'contract-breach',\n 'other',\n] as const\nexport type ArcReasonCode = (typeof ARC_REASON_CODES)[number]\n"],
|
|
5
|
+
"mappings": "AAWA,MAAM,mBAAmB;AAMzB,eAAsB,oBACpB,IACA,OACiB;AACjB,MAAI;AACF,UAAM,EAAE,SAAS,IAAI,MAAM,OACzB,qDACF;AACA,UAAM,OAAO,MAAM,GAAG,QAAQ,UAAU;AAAA,MACtC,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,QAAQ;AAAA,MACR,WAAW;AAAA,IACb,CAAC;AACD,WAAO,MAAM,QAAQ;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAIO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,MAAM,oBAAiC,CAAC,YAAY,YAAY,WAAW;AAQ3E,MAAM,sBAAsD,OAAO;AAAA,EACxE,aAAa,IAAI,CAAC,SAAS,CAAC,MAAM,aAAa,OAAO,CAAC,OAAO,OAAO,IAAI,CAAC,CAAC;AAC7E;AA2JO,MAAM,kCAAkC;AAAA,EAC7C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,qCAAyG;AAAA,EACpH,SAAS,CAAC,UAAU,YAAY;AAAA,EAChC,QAAQ,CAAC,aAAa,cAAc,WAAW,YAAY;AAAA,EAC3D,WAAW,CAAC,UAAU,cAAc,YAAY;AAAA,EAChD,YAAY,CAAC;AAAA,EACb,SAAS,CAAC;AAAA,EACV,YAAY,CAAC;AACf;AAEO,MAAM,uCAAuC;AAAA,EAClD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,2BAA2B,CAAC,WAAW,aAAa,UAAU,YAAY,WAAW;AAG3F,MAAM,8BAAoF;AAAA,EAC/F,SAAS,CAAC,aAAa,UAAU,WAAW;AAAA,EAC5C,WAAW,CAAC,UAAU,UAAU;AAAA,EAChC,QAAQ,CAAC,UAAU;AAAA,EACnB,UAAU,CAAC;AAAA,EACX,WAAW,CAAC;AACd;AAIO,MAAM,qBAAqB,CAAC,SAAS,sBAAsB,UAAU,aAAa,WAAW;AAG7F,MAAM,wBAAkE;AAAA,EAC7E,OAAO,CAAC,sBAAsB,UAAU,WAAW;AAAA,EACnD,oBAAoB,CAAC,UAAU,WAAW;AAAA,EAC1C,QAAQ,CAAC,aAAa,WAAW;AAAA,EACjC,WAAW,CAAC;AAAA,EACZ,WAAW,CAAC;AACd;AAIO,MAAM,kBAAkB,CAAC,OAAO,SAAS,SAAS,QAAQ;AAG1D,MAAM,kBAA+D,CAAC,SAAS,SAAS,QAAQ;AAEhG,MAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAGO,MAAM,gBAAgB,CAAC,cAAc,QAAQ;AAG7C,MAAM,mBAAmB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dainamite/cpq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Dainamite CPQ — Configure, Price, Quote engine for Open Mercato (configurable products, multi-charge pricing tables, guided quoting, customer inventory, orders).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/modules/cpq/index.js",
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
"watch": "node watch.mjs",
|
|
17
17
|
"typecheck": "tsc --noEmit"
|
|
18
18
|
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"xlsx": "^0.18.0"
|
|
21
|
+
},
|
|
19
22
|
"devDependencies": {
|
|
20
23
|
"chokidar": "^4.0.3"
|
|
21
24
|
},
|
|
@@ -118,13 +121,13 @@
|
|
|
118
121
|
}
|
|
119
122
|
},
|
|
120
123
|
"peerDependencies": {
|
|
121
|
-
"@mikro-orm/core": "^7.
|
|
122
|
-
"@mikro-orm/decorators": "^7.
|
|
123
|
-
"@mikro-orm/postgresql": "^7.
|
|
124
|
-
"@open-mercato/core": "^0.6.
|
|
125
|
-
"@open-mercato/events": "^0.6.
|
|
126
|
-
"@open-mercato/shared": "^0.6.
|
|
127
|
-
"@open-mercato/ui": "^0.6.
|
|
124
|
+
"@mikro-orm/core": "^7.1.5",
|
|
125
|
+
"@mikro-orm/decorators": "^7.1.5",
|
|
126
|
+
"@mikro-orm/postgresql": "^7.1.5",
|
|
127
|
+
"@open-mercato/core": "^0.6.7",
|
|
128
|
+
"@open-mercato/events": "^0.6.7",
|
|
129
|
+
"@open-mercato/shared": "^0.6.7",
|
|
130
|
+
"@open-mercato/ui": "^0.6.7",
|
|
128
131
|
"@tanstack/react-table": "^8.0.0 || ^9.0.0",
|
|
129
132
|
"awilix": "^12.0.0",
|
|
130
133
|
"bcryptjs": "^3.0.0",
|
|
@@ -132,7 +135,6 @@
|
|
|
132
135
|
"next": "^16.0.0",
|
|
133
136
|
"react": "^19.0.0",
|
|
134
137
|
"react-dom": "^19.0.0",
|
|
135
|
-
"xlsx": "^0.18.0",
|
|
136
138
|
"zod": "^4.0.0"
|
|
137
139
|
},
|
|
138
140
|
"publishConfig": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { test, expect } from '@playwright/test'
|
|
1
|
+
import { test, expect, type Locator } from '@playwright/test'
|
|
2
2
|
import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -24,7 +24,31 @@ import { login } from '@open-mercato/core/helpers/integration/auth'
|
|
|
24
24
|
// is unambiguous *and* doesn't depend on accessibility-name timing.
|
|
25
25
|
const pickerLocator = '[role="combobox"]'
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Open the Radix Select and wait until its options have mounted.
|
|
29
|
+
*
|
|
30
|
+
* The single `trigger.click()` was the flake source in CI: Radix can
|
|
31
|
+
* swallow the first click (the trigger isn't interactive yet on a slow
|
|
32
|
+
* cold render) or open-then-close the menu under load, so the option list
|
|
33
|
+
* never mounts and the following `toBeVisible` times out. Retrying the open
|
|
34
|
+
* until a known option is visible makes the step deterministic.
|
|
35
|
+
*/
|
|
36
|
+
async function openExpiringPicker(trigger: Locator, probe: Locator): Promise<void> {
|
|
37
|
+
// The inner timeout (4s) is comfortably longer than a normal option mount,
|
|
38
|
+
// so a menu that's merely slow to paint isn't mistaken for "didn't open"
|
|
39
|
+
// and re-clicked shut — the only re-click happens when the prior click was
|
|
40
|
+
// swallowed and the menu is actually closed. The 12s budget stays well
|
|
41
|
+
// under the 20s global test timeout so a genuine failure surfaces here
|
|
42
|
+
// (with a useful message) rather than as an opaque test timeout. No
|
|
43
|
+
// per-test timeout override (integration-tests SKILL.md).
|
|
44
|
+
await expect(async () => {
|
|
45
|
+
await trigger.click()
|
|
46
|
+
await expect(probe).toBeVisible({ timeout: 4_000 })
|
|
47
|
+
}).toPass({ timeout: 12_000 })
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
test.describe('TC-CPQ-012: Inline expiring picker (UI)', () => {
|
|
51
|
+
|
|
28
52
|
test('picker renders in title row with the 5 preset windows', async ({ page }) => {
|
|
29
53
|
await login(page, 'admin')
|
|
30
54
|
await page.goto('/backend/cpq/inventory', { waitUntil: 'domcontentloaded' })
|
|
@@ -37,7 +61,7 @@ test.describe('TC-CPQ-012: Inline expiring picker (UI)', () => {
|
|
|
37
61
|
|
|
38
62
|
// Open the dropdown and verify the 5 canonical preset windows are
|
|
39
63
|
// listed in the same order the operator's renewal-watch UX expects.
|
|
40
|
-
await picker.
|
|
64
|
+
await openExpiringPicker(picker, page.getByRole('option', { name: /Expiring in 30 days/i }))
|
|
41
65
|
for (const days of [7, 14, 30, 60, 90]) {
|
|
42
66
|
await expect(
|
|
43
67
|
page.getByRole('option', { name: new RegExp(`Expiring in ${days} days`, 'i') }),
|
|
@@ -51,8 +75,14 @@ test.describe('TC-CPQ-012: Inline expiring picker (UI)', () => {
|
|
|
51
75
|
|
|
52
76
|
const trigger = page.locator(pickerLocator).first()
|
|
53
77
|
await expect(trigger).toBeVisible({ timeout: 15_000 })
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
|
|
79
|
+
// Open the menu deterministically (retries the click until the option
|
|
80
|
+
// mounts — see `openExpiringPicker`), then force the click to bypass
|
|
81
|
+
// Radix's portal positioning layer that the default actionability check
|
|
82
|
+
// treats as obscuring the option; Radix's own onSelect still fires.
|
|
83
|
+
const option = page.getByRole('option', { name: /Expiring in 30 days/i })
|
|
84
|
+
await openExpiringPicker(trigger, option)
|
|
85
|
+
await option.click({ force: true })
|
|
56
86
|
|
|
57
87
|
// Same URL — the picker writes into the in-page filter state, not a
|
|
58
88
|
// route param. Picker now reflects the chosen window.
|
|
@@ -21,9 +21,14 @@ import { metadata } from '../index'
|
|
|
21
21
|
|
|
22
22
|
const PACKAGE_ROOT = path.resolve(__dirname, '..')
|
|
23
23
|
|
|
24
|
-
// Must mirror peerDependencies in packages/cpq/package.json
|
|
25
|
-
// Adding a prefix here without adding
|
|
26
|
-
//
|
|
24
|
+
// Must mirror peerDependencies + dependencies in packages/cpq/package.json
|
|
25
|
+
// (plus relative paths + node builtins). Adding a prefix here without adding
|
|
26
|
+
// it to one of those two = silent runtime failure for consumers that don't
|
|
27
|
+
// happen to have the dep transitively. Sibling framework packages
|
|
28
|
+
// (@open-mercato/*, react, mikro-orm, etc.) MUST stay as peerDependencies so
|
|
29
|
+
// yarn keeps a single shared instance; CPQ-internal leaf libs (xlsx) live in
|
|
30
|
+
// dependencies so a fresh `yarn add @dainamite/cpq` scaffold pulls them in
|
|
31
|
+
// without manual setup (XD-289).
|
|
27
32
|
const ALLOWED_IMPORT_PREFIXES = [
|
|
28
33
|
'./',
|
|
29
34
|
'../',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { serializeAttribute } from '../route'
|
|
2
|
+
import type { CpqProductAttribute } from '../../../data/entities'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* XD-292 regression: the create (POST) and update (PUT) responses used to omit
|
|
6
|
+
* `resolutionTime` (and several other fields), so a freshly created design-time
|
|
7
|
+
* attribute rendered as "run" in the spec detail table until a page refresh.
|
|
8
|
+
* serializeAttribute is now the single source of truth for the API shape, so
|
|
9
|
+
* these assertions guard every method that returns it.
|
|
10
|
+
*/
|
|
11
|
+
function makeAttribute(overrides: Partial<CpqProductAttribute> = {}): CpqProductAttribute {
|
|
12
|
+
const now = new Date('2026-06-01T00:00:00.000Z')
|
|
13
|
+
return {
|
|
14
|
+
id: 'attr-1',
|
|
15
|
+
productId: 'prod-1',
|
|
16
|
+
specId: 'spec-1',
|
|
17
|
+
code: 'platform-subscription-tier',
|
|
18
|
+
name: 'Platform Subscription Tier',
|
|
19
|
+
attributeType: 'select',
|
|
20
|
+
resolutionTime: 'design_time',
|
|
21
|
+
options: [{ value: 'a' }],
|
|
22
|
+
constraints: null,
|
|
23
|
+
referenceEntity: null,
|
|
24
|
+
referenceFilter: null,
|
|
25
|
+
dependsOn: { attributeCode: 'foo' },
|
|
26
|
+
defaultValue: 'a',
|
|
27
|
+
helpText: 'pick a tier',
|
|
28
|
+
sortOrder: 0,
|
|
29
|
+
isRequired: true,
|
|
30
|
+
isActive: true,
|
|
31
|
+
createdAt: now,
|
|
32
|
+
updatedAt: now,
|
|
33
|
+
...overrides,
|
|
34
|
+
} as CpqProductAttribute
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('serializeAttribute', () => {
|
|
38
|
+
it('includes resolutionTime so design-time attributes do not render as "run"', () => {
|
|
39
|
+
const out = serializeAttribute(makeAttribute({ resolutionTime: 'design_time' }))
|
|
40
|
+
expect(out.resolutionTime).toBe('design_time')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('preserves run_time resolution', () => {
|
|
44
|
+
const out = serializeAttribute(makeAttribute({ resolutionTime: 'run_time' }))
|
|
45
|
+
expect(out.resolutionTime).toBe('run_time')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('exposes every field the spec detail UI reads', () => {
|
|
49
|
+
const out = serializeAttribute(makeAttribute())
|
|
50
|
+
// The fields that were previously missing from POST/PUT responses.
|
|
51
|
+
expect(out).toMatchObject({
|
|
52
|
+
id: 'attr-1',
|
|
53
|
+
specId: 'spec-1',
|
|
54
|
+
resolutionTime: 'design_time',
|
|
55
|
+
referenceEntity: null,
|
|
56
|
+
referenceFilter: null,
|
|
57
|
+
dependsOn: { attributeCode: 'foo' },
|
|
58
|
+
defaultValue: 'a',
|
|
59
|
+
helpText: 'pick a tier',
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -11,6 +11,37 @@ export const metadata = {
|
|
|
11
11
|
DELETE: { requireAuth: true, requireFeatures: ['cpq.products.manage'] },
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Single source of truth for the attribute API shape. GET, POST and PUT MUST
|
|
16
|
+
* all return this exact projection so the client never sees a partial record.
|
|
17
|
+
* Omitting fields here (notably `resolutionTime`) caused XD-292: a freshly
|
|
18
|
+
* created design-time attribute rendered as "run" until a page refresh, since
|
|
19
|
+
* the create response lacked the field the table reads.
|
|
20
|
+
*/
|
|
21
|
+
export function serializeAttribute(item: CpqProductAttribute) {
|
|
22
|
+
return {
|
|
23
|
+
id: item.id,
|
|
24
|
+
productId: item.productId,
|
|
25
|
+
specId: item.specId,
|
|
26
|
+
code: item.code,
|
|
27
|
+
name: item.name,
|
|
28
|
+
attributeType: item.attributeType,
|
|
29
|
+
resolutionTime: item.resolutionTime,
|
|
30
|
+
options: item.options,
|
|
31
|
+
constraints: item.constraints,
|
|
32
|
+
referenceEntity: item.referenceEntity,
|
|
33
|
+
referenceFilter: item.referenceFilter,
|
|
34
|
+
dependsOn: item.dependsOn,
|
|
35
|
+
defaultValue: item.defaultValue,
|
|
36
|
+
helpText: item.helpText,
|
|
37
|
+
sortOrder: item.sortOrder,
|
|
38
|
+
isRequired: item.isRequired,
|
|
39
|
+
isActive: item.isActive,
|
|
40
|
+
createdAt: item.createdAt,
|
|
41
|
+
updatedAt: item.updatedAt,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
14
45
|
export async function GET(req: Request) {
|
|
15
46
|
try {
|
|
16
47
|
const ctx = await resolveCpqRouteContext(req)
|
|
@@ -35,27 +66,7 @@ export async function GET(req: Request) {
|
|
|
35
66
|
})
|
|
36
67
|
|
|
37
68
|
return NextResponse.json({
|
|
38
|
-
items: items.map(
|
|
39
|
-
id: item.id,
|
|
40
|
-
productId: item.productId,
|
|
41
|
-
specId: item.specId,
|
|
42
|
-
code: item.code,
|
|
43
|
-
name: item.name,
|
|
44
|
-
attributeType: item.attributeType,
|
|
45
|
-
resolutionTime: item.resolutionTime,
|
|
46
|
-
options: item.options,
|
|
47
|
-
constraints: item.constraints,
|
|
48
|
-
referenceEntity: item.referenceEntity,
|
|
49
|
-
referenceFilter: item.referenceFilter,
|
|
50
|
-
dependsOn: item.dependsOn,
|
|
51
|
-
defaultValue: item.defaultValue,
|
|
52
|
-
helpText: item.helpText,
|
|
53
|
-
sortOrder: item.sortOrder,
|
|
54
|
-
isRequired: item.isRequired,
|
|
55
|
-
isActive: item.isActive,
|
|
56
|
-
createdAt: item.createdAt,
|
|
57
|
-
updatedAt: item.updatedAt,
|
|
58
|
-
})),
|
|
69
|
+
items: items.map(serializeAttribute),
|
|
59
70
|
total,
|
|
60
71
|
page,
|
|
61
72
|
pageSize,
|
|
@@ -88,23 +99,7 @@ export async function POST(req: Request) {
|
|
|
88
99
|
const entity = ctx.em.create(CpqProductAttribute, { ...body, productId, ...scope })
|
|
89
100
|
await ctx.em.flush()
|
|
90
101
|
|
|
91
|
-
return NextResponse.json(
|
|
92
|
-
{
|
|
93
|
-
id: entity.id,
|
|
94
|
-
productId: entity.productId,
|
|
95
|
-
code: entity.code,
|
|
96
|
-
name: entity.name,
|
|
97
|
-
attributeType: entity.attributeType,
|
|
98
|
-
options: entity.options,
|
|
99
|
-
constraints: entity.constraints,
|
|
100
|
-
sortOrder: entity.sortOrder,
|
|
101
|
-
isRequired: entity.isRequired,
|
|
102
|
-
isActive: entity.isActive,
|
|
103
|
-
createdAt: entity.createdAt,
|
|
104
|
-
updatedAt: entity.updatedAt,
|
|
105
|
-
},
|
|
106
|
-
{ status: 201 },
|
|
107
|
-
)
|
|
102
|
+
return NextResponse.json(serializeAttribute(entity), { status: 201 })
|
|
108
103
|
} catch (err) {
|
|
109
104
|
if (err instanceof z.ZodError) {
|
|
110
105
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
@@ -129,20 +124,7 @@ export async function PUT(req: Request) {
|
|
|
129
124
|
ctx.em.assign(entity, updates)
|
|
130
125
|
await ctx.em.flush()
|
|
131
126
|
|
|
132
|
-
return NextResponse.json(
|
|
133
|
-
id: entity.id,
|
|
134
|
-
productId: entity.productId,
|
|
135
|
-
code: entity.code,
|
|
136
|
-
name: entity.name,
|
|
137
|
-
attributeType: entity.attributeType,
|
|
138
|
-
options: entity.options,
|
|
139
|
-
constraints: entity.constraints,
|
|
140
|
-
sortOrder: entity.sortOrder,
|
|
141
|
-
isRequired: entity.isRequired,
|
|
142
|
-
isActive: entity.isActive,
|
|
143
|
-
createdAt: entity.createdAt,
|
|
144
|
-
updatedAt: entity.updatedAt,
|
|
145
|
-
})
|
|
127
|
+
return NextResponse.json(serializeAttribute(entity))
|
|
146
128
|
} catch (err) {
|
|
147
129
|
if (err instanceof z.ZodError) {
|
|
148
130
|
return NextResponse.json({ error: 'Validation error', details: err.issues }, { status: 400 })
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { resolveCpqRouteContext } from '../context'
|
|
4
4
|
import { CpqProductCharge, CpqProductOffering, CpqProductSpecification } from '../../data/entities'
|
|
5
5
|
import { cpqProductChargeCreateSchema, cpqProductChargeUpdateSchema } from '../../data/validators'
|
|
6
|
+
import { normalizeChargePricing } from '../../data/charge-pricing'
|
|
6
7
|
|
|
7
8
|
export const metadata = {
|
|
8
9
|
GET: { requireAuth: true, requireFeatures: ['cpq.products.view'] },
|
|
@@ -43,6 +44,7 @@ export async function GET(req: Request) {
|
|
|
43
44
|
name: item.name,
|
|
44
45
|
description: item.description,
|
|
45
46
|
chargeType: item.chargeType,
|
|
47
|
+
chargeModel: item.chargeModel,
|
|
46
48
|
pricingMethod: item.pricingMethod,
|
|
47
49
|
pricingTableId: item.pricingTableId,
|
|
48
50
|
priceColumnKey: item.priceColumnKey,
|
|
@@ -87,7 +89,10 @@ export async function POST(req: Request) {
|
|
|
87
89
|
return NextResponse.json({ error: 'productId or offeringId with valid offering required' }, { status: 400 })
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
|
|
92
|
+
// Persist the canonical split shape regardless of whether the client sent
|
|
93
|
+
// the new fields or a legacy combined pricingMethod.
|
|
94
|
+
const { model, source } = normalizeChargePricing(body)
|
|
95
|
+
const entity = ctx.em.create(CpqProductCharge, { ...body, chargeModel: model, pricingMethod: source, productId, ...scope })
|
|
91
96
|
await ctx.em.flush()
|
|
92
97
|
|
|
93
98
|
return NextResponse.json(
|
|
@@ -97,6 +102,7 @@ export async function POST(req: Request) {
|
|
|
97
102
|
code: entity.code,
|
|
98
103
|
name: entity.name,
|
|
99
104
|
chargeType: entity.chargeType,
|
|
105
|
+
chargeModel: entity.chargeModel,
|
|
100
106
|
pricingMethod: entity.pricingMethod,
|
|
101
107
|
pricingTableId: entity.pricingTableId,
|
|
102
108
|
priceColumnKey: entity.priceColumnKey,
|
|
@@ -130,7 +136,21 @@ export async function PUT(req: Request) {
|
|
|
130
136
|
const entity = await ctx.em.findOne(CpqProductCharge, { id, ...scope })
|
|
131
137
|
if (!entity) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
132
138
|
|
|
133
|
-
|
|
139
|
+
// Re-derive the canonical split shape when pricing fields are being changed,
|
|
140
|
+
// and clear the opposite axis' fields so a partial PATCH can't leave a
|
|
141
|
+
// stale pricingTableId on a fixed charge (or a stale fixedPrice on a table
|
|
142
|
+
// charge) that the patch-only validator never sees.
|
|
143
|
+
const normalizedUpdates =
|
|
144
|
+
updates.pricingMethod !== undefined || updates.chargeModel !== undefined
|
|
145
|
+
? (() => {
|
|
146
|
+
const { model, source } = normalizeChargePricing({ ...entity, ...updates })
|
|
147
|
+
const sideCleared = source === 'fixed'
|
|
148
|
+
? { pricingTableId: null, priceColumnKey: null }
|
|
149
|
+
: { fixedPrice: null }
|
|
150
|
+
return { ...updates, chargeModel: model, pricingMethod: source, ...sideCleared }
|
|
151
|
+
})()
|
|
152
|
+
: updates
|
|
153
|
+
ctx.em.assign(entity, normalizedUpdates)
|
|
134
154
|
await ctx.em.flush()
|
|
135
155
|
|
|
136
156
|
return NextResponse.json({
|
|
@@ -139,6 +159,7 @@ export async function PUT(req: Request) {
|
|
|
139
159
|
code: entity.code,
|
|
140
160
|
name: entity.name,
|
|
141
161
|
chargeType: entity.chargeType,
|
|
162
|
+
chargeModel: entity.chargeModel,
|
|
142
163
|
pricingMethod: entity.pricingMethod,
|
|
143
164
|
pricingTableId: entity.pricingTableId,
|
|
144
165
|
priceColumnKey: entity.priceColumnKey,
|
|
@@ -91,6 +91,7 @@ export async function GET(req: Request) {
|
|
|
91
91
|
name: c.name,
|
|
92
92
|
description: c.description,
|
|
93
93
|
chargeType: c.chargeType,
|
|
94
|
+
chargeModel: c.chargeModel,
|
|
94
95
|
pricingMethod: c.pricingMethod,
|
|
95
96
|
pricingTableId: c.pricingTableId,
|
|
96
97
|
priceColumnKey: c.priceColumnKey,
|
|
@@ -165,6 +166,7 @@ export async function GET(req: Request) {
|
|
|
165
166
|
name: c.name,
|
|
166
167
|
description: c.description,
|
|
167
168
|
chargeType: c.chargeType,
|
|
169
|
+
chargeModel: c.chargeModel,
|
|
168
170
|
pricingMethod: c.pricingMethod,
|
|
169
171
|
pricingTableId: c.pricingTableId,
|
|
170
172
|
priceColumnKey: c.priceColumnKey,
|