@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
|
@@ -5,7 +5,9 @@ import { useParams, useRouter } from 'next/navigation'
|
|
|
5
5
|
import { Alert } from '@open-mercato/ui/primitives/alert'
|
|
6
6
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
7
|
import { Tag } from '@open-mercato/ui/primitives/tag'
|
|
8
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
8
9
|
import { NumberInput } from '../../../../components/NumberInput'
|
|
10
|
+
import { normalizeChargePricing } from '../../../../data/charge-pricing'
|
|
9
11
|
import {
|
|
10
12
|
chargeTypeMap,
|
|
11
13
|
formatStatusLabel,
|
|
@@ -63,6 +65,7 @@ type Charge = {
|
|
|
63
65
|
name: string
|
|
64
66
|
description: string | null
|
|
65
67
|
chargeType: string
|
|
68
|
+
chargeModel: string | null
|
|
66
69
|
pricingMethod: string
|
|
67
70
|
pricingTableId: string | null
|
|
68
71
|
priceColumnKey: string | null
|
|
@@ -98,6 +101,22 @@ type Specification = {
|
|
|
98
101
|
|
|
99
102
|
type PricingTableRef = { id: string; code: string; name: string; dimensions: Array<{ key: string; label: string }>; priceColumns: Array<{ key: string; label: string }> }
|
|
100
103
|
|
|
104
|
+
// Shared by the eager mount-load and the lazy editor dropdown. Returns [] on
|
|
105
|
+
// failure so a transient error degrades gracefully instead of blanking the page.
|
|
106
|
+
async function fetchPricingTables(): Promise<PricingTableRef[]> {
|
|
107
|
+
try {
|
|
108
|
+
const { ok, result } = await apiCall<{ items?: PricingTableRef[] }>('/api/cpq/pricing-tables?pageSize=100')
|
|
109
|
+
if (!ok) {
|
|
110
|
+
console.warn('[cpq] Failed to load pricing tables for the charges list')
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
return result?.items ?? []
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.warn('[cpq] Failed to load pricing tables for the charges list:', err)
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
type EditingCharge = Omit<Charge, 'id'> & { id?: string }
|
|
102
121
|
|
|
103
122
|
// ─── Defaults ───────────────────────────────────────────────────
|
|
@@ -119,7 +138,8 @@ const EMPTY_CHARGE: EditingCharge = {
|
|
|
119
138
|
name: '',
|
|
120
139
|
description: null,
|
|
121
140
|
chargeType: 'mrc',
|
|
122
|
-
|
|
141
|
+
chargeModel: 'flat',
|
|
142
|
+
pricingMethod: 'fixed',
|
|
123
143
|
pricingTableId: null,
|
|
124
144
|
priceColumnKey: null,
|
|
125
145
|
fixedPrice: null,
|
|
@@ -131,17 +151,24 @@ const EMPTY_CHARGE: EditingCharge = {
|
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
154
|
+
// XD-297: the charge "shape" is two axes — Charge Model (how quantity drives
|
|
155
|
+
// the charge) and Pricing Method (where the unit price comes from).
|
|
156
|
+
const CHARGE_MODEL_LABELS: Record<string, string> = {
|
|
157
|
+
flat: 'Flat Fee',
|
|
158
|
+
per_unit: 'Per Unit',
|
|
159
|
+
volume: 'Volume-based',
|
|
160
|
+
tiered: 'Tiered',
|
|
139
161
|
}
|
|
140
162
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
163
|
+
const PRICING_SOURCE_LABELS: Record<string, string> = {
|
|
164
|
+
fixed: 'Fixed Price',
|
|
165
|
+
table: 'Table Lookup',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// V-CHG-1 in `validators.ts`: mirror the (chargeModel × pricingMethod) rules in
|
|
169
|
+
// the UI so users see what's missing without round-tripping to the server.
|
|
144
170
|
type ChargeShape = {
|
|
171
|
+
chargeModel: string | null
|
|
145
172
|
pricingMethod: string
|
|
146
173
|
pricingTableId: string | null
|
|
147
174
|
priceColumnKey: string | null
|
|
@@ -155,15 +182,16 @@ function isChargeComplete(charge: ChargeShape): boolean {
|
|
|
155
182
|
}
|
|
156
183
|
|
|
157
184
|
function chargeMissingFields(charge: ChargeShape): string[] {
|
|
158
|
-
const
|
|
185
|
+
const { model, source } = normalizeChargePricing(charge)
|
|
159
186
|
const missing: string[] = []
|
|
160
|
-
if (
|
|
187
|
+
if (source === 'fixed') {
|
|
161
188
|
if (!charge.fixedPrice) missing.push('Fixed Price')
|
|
162
189
|
if (!charge.currencyCode) missing.push('Currency')
|
|
163
|
-
|
|
190
|
+
if (model === 'per_unit' && !charge.quantityAttributeCode) missing.push('Quantity Attribute')
|
|
191
|
+
} else {
|
|
164
192
|
if (!charge.pricingTableId) missing.push('Pricing Table')
|
|
165
193
|
if (!charge.priceColumnKey) missing.push('Price Column')
|
|
166
|
-
if (!charge.quantityAttributeCode) missing.push('Quantity Attribute')
|
|
194
|
+
if (model !== 'flat' && !charge.quantityAttributeCode) missing.push('Quantity Attribute')
|
|
167
195
|
}
|
|
168
196
|
return missing
|
|
169
197
|
}
|
|
@@ -245,6 +273,14 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
245
273
|
setSpecDetail(data.specification ?? null)
|
|
246
274
|
setComponents(data.components ?? [])
|
|
247
275
|
|
|
276
|
+
// Charges reference pricing tables by id; load the tables up front so
|
|
277
|
+
// the list renders their names instead of "missing". The charge editor
|
|
278
|
+
// also lazy-loads them, but the read view needs them on mount/refresh.
|
|
279
|
+
if ((data.charges ?? []).some((c) => c.pricingTableId)) {
|
|
280
|
+
const tables = await fetchPricingTables()
|
|
281
|
+
if (!cancelled) setPricingTables(tables)
|
|
282
|
+
}
|
|
283
|
+
|
|
248
284
|
// Load attributes for the spec
|
|
249
285
|
if (data.specId) {
|
|
250
286
|
const attrRes = await fetch(`/api/cpq/product-attributes?specId=${encodeURIComponent(data.specId)}&pageSize=200`)
|
|
@@ -362,16 +398,18 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
362
398
|
|
|
363
399
|
const loadPricingTables = async () => {
|
|
364
400
|
if (pricingTables.length > 0) return
|
|
365
|
-
|
|
366
|
-
if (res.ok) {
|
|
367
|
-
const data = await res.json()
|
|
368
|
-
setPricingTables(data.items ?? [])
|
|
369
|
-
}
|
|
401
|
+
setPricingTables(await fetchPricingTables())
|
|
370
402
|
}
|
|
371
403
|
|
|
372
404
|
const startEditCharge = (charge?: Charge) => {
|
|
373
|
-
|
|
374
|
-
|
|
405
|
+
if (charge) {
|
|
406
|
+
// Normalise legacy rows (chargeModel null, combined pricingMethod) into
|
|
407
|
+
// the split shape the form's two dropdowns expect.
|
|
408
|
+
const { model, source } = normalizeChargePricing(charge)
|
|
409
|
+
setEditingCharge({ ...charge, chargeModel: model, pricingMethod: source })
|
|
410
|
+
} else {
|
|
411
|
+
setEditingCharge({ ...EMPTY_CHARGE })
|
|
412
|
+
}
|
|
375
413
|
loadPricingTables()
|
|
376
414
|
}
|
|
377
415
|
|
|
@@ -759,22 +797,55 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
759
797
|
<option value="usage">Usage</option>
|
|
760
798
|
</select>
|
|
761
799
|
</div>
|
|
800
|
+
<div>
|
|
801
|
+
<label className="block text-xs font-medium mb-1">Charge Model</label>
|
|
802
|
+
<select
|
|
803
|
+
value={editingCharge.chargeModel ?? 'flat'}
|
|
804
|
+
onChange={(e) => {
|
|
805
|
+
const model = e.target.value
|
|
806
|
+
const next = { ...editingCharge, chargeModel: model }
|
|
807
|
+
// Volume / tiered are graduated over table ranges — they can't use a fixed price.
|
|
808
|
+
if ((model === 'volume' || model === 'tiered') && editingCharge.pricingMethod !== 'table') {
|
|
809
|
+
next.pricingMethod = 'table'
|
|
810
|
+
next.fixedPrice = null
|
|
811
|
+
}
|
|
812
|
+
// Flat never takes a quantity attribute.
|
|
813
|
+
if (model === 'flat') next.quantityAttributeCode = null
|
|
814
|
+
setEditingCharge(next)
|
|
815
|
+
}}
|
|
816
|
+
className="w-full rounded-md border px-2 py-1.5 text-sm"
|
|
817
|
+
>
|
|
818
|
+
<option value="flat">Flat Fee</option>
|
|
819
|
+
<option value="per_unit">Per Unit</option>
|
|
820
|
+
<option value="volume">Volume-based</option>
|
|
821
|
+
<option value="tiered">Tiered</option>
|
|
822
|
+
</select>
|
|
823
|
+
</div>
|
|
762
824
|
<div>
|
|
763
825
|
<label className="block text-xs font-medium mb-1">Pricing Method</label>
|
|
764
|
-
<select
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
826
|
+
<select
|
|
827
|
+
value={editingCharge.pricingMethod}
|
|
828
|
+
onChange={(e) => {
|
|
829
|
+
const source = e.target.value
|
|
830
|
+
if (source === 'fixed') {
|
|
831
|
+
// Only per_unit keeps the quantity attribute under a fixed
|
|
832
|
+
// price; flat must drop it (V-CHG-1 forbids it).
|
|
833
|
+
const keepQty = editingCharge.chargeModel === 'per_unit'
|
|
834
|
+
setEditingCharge({ ...editingCharge, pricingMethod: source, pricingTableId: null, priceColumnKey: null, ...(keepQty ? {} : { quantityAttributeCode: null }) })
|
|
835
|
+
} else {
|
|
836
|
+
setEditingCharge({ ...editingCharge, pricingMethod: source, fixedPrice: null })
|
|
837
|
+
}
|
|
838
|
+
}}
|
|
839
|
+
className="w-full rounded-md border px-2 py-1.5 text-sm"
|
|
840
|
+
>
|
|
841
|
+
{/* Volume / tiered are graduated over table ranges — a fixed price isn't applicable. */}
|
|
842
|
+
{editingCharge.chargeModel !== 'volume' && editingCharge.chargeModel !== 'tiered' && (
|
|
843
|
+
<option value="fixed">Fixed Price</option>
|
|
844
|
+
)}
|
|
845
|
+
<option value="table">Table Lookup</option>
|
|
775
846
|
</select>
|
|
776
847
|
</div>
|
|
777
|
-
{
|
|
848
|
+
{editingCharge.pricingMethod === 'fixed' ? (
|
|
778
849
|
<>
|
|
779
850
|
<div>
|
|
780
851
|
<label className="block text-xs font-medium mb-1">Fixed Price <span className="text-destructive">*</span></label>
|
|
@@ -799,7 +870,7 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
799
870
|
<p className="text-xs text-warning mt-0.5">
|
|
800
871
|
No pricing tables yet —{' '}
|
|
801
872
|
<a href="/backend/cpq/pricing-tables" className="text-primary hover:underline">create one</a>{' '}
|
|
802
|
-
before using
|
|
873
|
+
before using Table Lookup pricing.
|
|
803
874
|
</p>
|
|
804
875
|
)}
|
|
805
876
|
</div>
|
|
@@ -812,27 +883,32 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
812
883
|
))}
|
|
813
884
|
</select>
|
|
814
885
|
</div>
|
|
815
|
-
<div>
|
|
816
|
-
<label className="block text-xs font-medium mb-1">Quantity Attribute <span className="text-destructive">*</span></label>
|
|
817
|
-
<select value={editingCharge.quantityAttributeCode ?? ''} onChange={(e) => setEditingCharge({ ...editingCharge, quantityAttributeCode: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm">
|
|
818
|
-
<option value="">Select attribute...</option>
|
|
819
|
-
{attributes.filter((a) => a.attributeType === 'number').map((a) => (
|
|
820
|
-
<option key={a.code} value={a.code}>{a.name} ({a.code})</option>
|
|
821
|
-
))}
|
|
822
|
-
</select>
|
|
823
|
-
<p className="text-xs text-muted-foreground mt-0.5">Attribute whose value drives the quantity for pricing</p>
|
|
824
|
-
{attributes.filter((a) => a.attributeType === 'number').length === 0 && form.specId && (
|
|
825
|
-
<p className="text-xs text-warning mt-0.5">
|
|
826
|
-
Spec has no number attributes —{' '}
|
|
827
|
-
<a href={`/backend/cpq/specifications/${form.specId}`} className="text-primary hover:underline">
|
|
828
|
-
add one to the spec
|
|
829
|
-
</a>{' '}
|
|
830
|
-
first.
|
|
831
|
-
</p>
|
|
832
|
-
)}
|
|
833
|
-
</div>
|
|
834
886
|
</>
|
|
835
887
|
)}
|
|
888
|
+
{/* Quantity attribute drives the unit count — needed for per-unit
|
|
889
|
+
(any source) and for volume / tiered table lookups. */}
|
|
890
|
+
{((editingCharge.pricingMethod === 'table' && editingCharge.chargeModel !== 'flat') ||
|
|
891
|
+
(editingCharge.pricingMethod === 'fixed' && editingCharge.chargeModel === 'per_unit')) && (
|
|
892
|
+
<div>
|
|
893
|
+
<label className="block text-xs font-medium mb-1">Quantity Attribute <span className="text-destructive">*</span></label>
|
|
894
|
+
<select value={editingCharge.quantityAttributeCode ?? ''} onChange={(e) => setEditingCharge({ ...editingCharge, quantityAttributeCode: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm">
|
|
895
|
+
<option value="">Select attribute...</option>
|
|
896
|
+
{attributes.filter((a) => a.attributeType === 'number').map((a) => (
|
|
897
|
+
<option key={a.code} value={a.code}>{a.name} ({a.code})</option>
|
|
898
|
+
))}
|
|
899
|
+
</select>
|
|
900
|
+
<p className="text-xs text-muted-foreground mt-0.5">Attribute whose value drives the quantity for pricing</p>
|
|
901
|
+
{attributes.filter((a) => a.attributeType === 'number').length === 0 && form.specId && (
|
|
902
|
+
<p className="text-xs text-warning mt-0.5">
|
|
903
|
+
Spec has no number attributes —{' '}
|
|
904
|
+
<a href={`/backend/cpq/specifications/${form.specId}`} className="text-primary hover:underline">
|
|
905
|
+
add one to the spec
|
|
906
|
+
</a>{' '}
|
|
907
|
+
first.
|
|
908
|
+
</p>
|
|
909
|
+
)}
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
836
912
|
<div>
|
|
837
913
|
<label className="block text-xs font-medium mb-1">Sort Order</label>
|
|
838
914
|
<NumberInput
|
|
@@ -902,7 +978,7 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
902
978
|
const qtyAttr = charge.quantityAttributeCode
|
|
903
979
|
? attributes.find((a) => a.code === charge.quantityAttributeCode)
|
|
904
980
|
: null
|
|
905
|
-
const
|
|
981
|
+
const { model, source } = normalizeChargePricing(charge)
|
|
906
982
|
return (
|
|
907
983
|
<tr key={charge.id} className="border-b">
|
|
908
984
|
<td className="px-4 py-3 font-mono text-xs">{charge.code}</td>
|
|
@@ -917,22 +993,35 @@ export default function OfferingDetailPage(props: { params?: { id?: string } })
|
|
|
917
993
|
{charge.chargeType.toUpperCase()}
|
|
918
994
|
</Tag>
|
|
919
995
|
</td>
|
|
920
|
-
<td className="px-4 py-3 text-xs">{PRICING_METHOD_LABELS[charge.pricingMethod] ?? charge.pricingMethod}</td>
|
|
921
996
|
<td className="px-4 py-3 text-xs">
|
|
922
|
-
{
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
997
|
+
{CHARGE_MODEL_LABELS[model] ?? model}
|
|
998
|
+
<span className="text-muted-foreground"> · {PRICING_SOURCE_LABELS[source] ?? source}</span>
|
|
999
|
+
</td>
|
|
1000
|
+
<td className="px-4 py-3 text-xs">
|
|
1001
|
+
{source === 'fixed' ? (
|
|
1002
|
+
<div className="flex flex-col gap-0.5">
|
|
1003
|
+
{charge.fixedPrice != null
|
|
1004
|
+
? <span>{`${charge.currencyCode ?? 'USD'} ${charge.fixedPrice}`}</span>
|
|
1005
|
+
: <span className="text-muted-foreground">—</span>}
|
|
1006
|
+
{model === 'per_unit' && (
|
|
1007
|
+
<span>
|
|
1008
|
+
<span className="text-muted-foreground">× Qty:</span>{' '}
|
|
1009
|
+
{qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : <span className="text-destructive">missing</span>}
|
|
1010
|
+
</span>
|
|
1011
|
+
)}
|
|
1012
|
+
</div>
|
|
926
1013
|
) : (
|
|
927
1014
|
<div className="flex flex-col gap-0.5">
|
|
928
1015
|
<span>
|
|
929
1016
|
<span className="text-muted-foreground">Table:</span>{' '}
|
|
930
1017
|
{tableRef ? `${tableRef.name} → ${charge.priceColumnKey ?? '?'}` : <span className="text-destructive">missing</span>}
|
|
931
1018
|
</span>
|
|
932
|
-
|
|
933
|
-
<span
|
|
934
|
-
|
|
935
|
-
|
|
1019
|
+
{model !== 'flat' && (
|
|
1020
|
+
<span>
|
|
1021
|
+
<span className="text-muted-foreground">× Qty:</span>{' '}
|
|
1022
|
+
{qtyAttr ? `${qtyAttr.name} (${qtyAttr.code})` : <span className="text-destructive">missing</span>}
|
|
1023
|
+
</span>
|
|
1024
|
+
)}
|
|
936
1025
|
</div>
|
|
937
1026
|
)}
|
|
938
1027
|
</td>
|
|
@@ -247,6 +247,13 @@ export function EntriesEditor({
|
|
|
247
247
|
<table className="w-full text-sm">
|
|
248
248
|
<thead>
|
|
249
249
|
<tr className="border-b bg-muted/50">
|
|
250
|
+
{table.dimensions.map((dim) => (
|
|
251
|
+
<th key={dim.key} className="px-3 py-3 text-left font-medium whitespace-nowrap">
|
|
252
|
+
{dim.label}
|
|
253
|
+
</th>
|
|
254
|
+
))}
|
|
255
|
+
{/* Tier / range columns sit between the dimension lookups and the
|
|
256
|
+
prices they qualify, so the row reads "what → how much". */}
|
|
250
257
|
{hasTieredEntries && (
|
|
251
258
|
<>
|
|
252
259
|
<th className="px-3 py-3 text-left font-medium whitespace-nowrap">Tier #</th>
|
|
@@ -254,11 +261,6 @@ export function EntriesEditor({
|
|
|
254
261
|
<th className="px-3 py-3 text-left font-medium whitespace-nowrap">Range To</th>
|
|
255
262
|
</>
|
|
256
263
|
)}
|
|
257
|
-
{table.dimensions.map((dim) => (
|
|
258
|
-
<th key={dim.key} className="px-3 py-3 text-left font-medium whitespace-nowrap">
|
|
259
|
-
{dim.label}
|
|
260
|
-
</th>
|
|
261
|
-
))}
|
|
262
264
|
{table.priceColumns.map((col) => (
|
|
263
265
|
<th key={col.key} className="px-3 py-3 text-right font-medium whitespace-nowrap">
|
|
264
266
|
{col.label}
|
|
@@ -314,6 +316,23 @@ function EntryRow({
|
|
|
314
316
|
|
|
315
317
|
return (
|
|
316
318
|
<tr className={`border-b hover:bg-muted/30 ${rowBg}`}>
|
|
319
|
+
{table.dimensions.map((dim) => (
|
|
320
|
+
<td key={dim.key} className="px-3 py-2">
|
|
321
|
+
{readOnly ? (
|
|
322
|
+
entry.dimensionValues?.[dim.key] ?? '—'
|
|
323
|
+
) : (
|
|
324
|
+
<Input
|
|
325
|
+
value={entry.dimensionValues?.[dim.key] ?? ''}
|
|
326
|
+
onChange={(e) =>
|
|
327
|
+
onChange({
|
|
328
|
+
dimensionValues: { ...entry.dimensionValues, [dim.key]: e.target.value },
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
className="w-28 h-8"
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
</td>
|
|
335
|
+
))}
|
|
317
336
|
{hasTieredEntries && (
|
|
318
337
|
<>
|
|
319
338
|
<td className="px-3 py-2">
|
|
@@ -355,23 +374,6 @@ function EntryRow({
|
|
|
355
374
|
</td>
|
|
356
375
|
</>
|
|
357
376
|
)}
|
|
358
|
-
{table.dimensions.map((dim) => (
|
|
359
|
-
<td key={dim.key} className="px-3 py-2">
|
|
360
|
-
{readOnly ? (
|
|
361
|
-
entry.dimensionValues?.[dim.key] ?? '—'
|
|
362
|
-
) : (
|
|
363
|
-
<Input
|
|
364
|
-
value={entry.dimensionValues?.[dim.key] ?? ''}
|
|
365
|
-
onChange={(e) =>
|
|
366
|
-
onChange({
|
|
367
|
-
dimensionValues: { ...entry.dimensionValues, [dim.key]: e.target.value },
|
|
368
|
-
})
|
|
369
|
-
}
|
|
370
|
-
className="w-28 h-8"
|
|
371
|
-
/>
|
|
372
|
-
)}
|
|
373
|
-
</td>
|
|
374
|
-
))}
|
|
375
377
|
{table.priceColumns.map((col) => (
|
|
376
378
|
<td key={col.key} className="px-3 py-2 text-right font-mono">
|
|
377
379
|
{readOnly ? (
|
|
@@ -999,7 +999,10 @@ export default function SpecificationDetailPage(props: { params?: { id?: string
|
|
|
999
999
|
</div>
|
|
1000
1000
|
<div>
|
|
1001
1001
|
<label className="block text-xs font-medium mb-1">Type</label>
|
|
1002
|
-
|
|
1002
|
+
{/* Reset the default when the type changes — a default kept from a
|
|
1003
|
+
different type (e.g. a numeric default on a now-boolean attr)
|
|
1004
|
+
would be invalid and confusing. */}
|
|
1005
|
+
<select value={editingAttr.attributeType} onChange={(e) => setEditingAttr({ ...editingAttr, attributeType: e.target.value, defaultValue: null })} className="w-full rounded-md border px-2 py-1.5 text-sm">
|
|
1003
1006
|
<option value="text">Text</option>
|
|
1004
1007
|
<option value="number">Number</option>
|
|
1005
1008
|
<option value="boolean">Boolean</option>
|
|
@@ -1027,6 +1030,75 @@ export default function SpecificationDetailPage(props: { params?: { id?: string
|
|
|
1027
1030
|
<label className="block text-xs font-medium mb-1">Help Text</label>
|
|
1028
1031
|
<input type="text" value={editingAttr.helpText ?? ''} onChange={(e) => setEditingAttr({ ...editingAttr, helpText: e.target.value || null })} className="w-full rounded-md border px-2 py-1.5 text-sm" />
|
|
1029
1032
|
</div>
|
|
1033
|
+
{/* Reference attributes resolve to an FK UUID at run-time, so a
|
|
1034
|
+
free-text scalar default has no sensible meaning — omit the
|
|
1035
|
+
field entirely for them. */}
|
|
1036
|
+
{editingAttr.attributeType !== 'reference' && (
|
|
1037
|
+
<div>
|
|
1038
|
+
<label className="block text-xs font-medium mb-1">{t('cpq.specifications.defaultValue', 'Default Value')}</label>
|
|
1039
|
+
{editingAttr.attributeType === 'number' ? (
|
|
1040
|
+
<NumberInput
|
|
1041
|
+
value={typeof editingAttr.defaultValue === 'number' ? editingAttr.defaultValue : null}
|
|
1042
|
+
onChange={(n) => setEditingAttr({ ...editingAttr, defaultValue: n })}
|
|
1043
|
+
placeholder={t('cpq.specifications.noDefault', 'No default')}
|
|
1044
|
+
/>
|
|
1045
|
+
) : editingAttr.attributeType === 'boolean' ? (
|
|
1046
|
+
<select
|
|
1047
|
+
value={editingAttr.defaultValue === true ? 'true' : editingAttr.defaultValue === false ? 'false' : ''}
|
|
1048
|
+
onChange={(e) => setEditingAttr({ ...editingAttr, defaultValue: e.target.value === '' ? null : e.target.value === 'true' })}
|
|
1049
|
+
className="w-full rounded-md border px-2 py-1.5 text-sm"
|
|
1050
|
+
>
|
|
1051
|
+
<option value="">{t('cpq.specifications.noDefault', 'No default')}</option>
|
|
1052
|
+
<option value="true">{t('cpq.specifications.booleanTrue', 'True')}</option>
|
|
1053
|
+
<option value="false">{t('cpq.specifications.booleanFalse', 'False')}</option>
|
|
1054
|
+
</select>
|
|
1055
|
+
) : (editingAttr.attributeType === 'select' || editingAttr.attributeType === 'multi-select') ? (
|
|
1056
|
+
<>
|
|
1057
|
+
<select
|
|
1058
|
+
multiple={editingAttr.attributeType === 'multi-select'}
|
|
1059
|
+
value={
|
|
1060
|
+
editingAttr.attributeType === 'multi-select'
|
|
1061
|
+
? (Array.isArray(editingAttr.defaultValue) ? editingAttr.defaultValue.map(String) : [])
|
|
1062
|
+
: (editingAttr.defaultValue == null ? '' : String(editingAttr.defaultValue))
|
|
1063
|
+
}
|
|
1064
|
+
onChange={(e) => {
|
|
1065
|
+
if (editingAttr.attributeType === 'multi-select') {
|
|
1066
|
+
const vals = Array.from(e.target.selectedOptions).map((o) => o.value).filter((v) => v !== '')
|
|
1067
|
+
setEditingAttr({ ...editingAttr, defaultValue: vals.length ? vals : null })
|
|
1068
|
+
} else {
|
|
1069
|
+
setEditingAttr({ ...editingAttr, defaultValue: e.target.value === '' ? null : e.target.value })
|
|
1070
|
+
}
|
|
1071
|
+
}}
|
|
1072
|
+
className="w-full rounded-md border px-2 py-1.5 text-sm"
|
|
1073
|
+
>
|
|
1074
|
+
{/* `multiple` selects can't carry a placeholder row, so only the
|
|
1075
|
+
single-select variant gets an explicit "No default" option. */}
|
|
1076
|
+
{editingAttr.attributeType === 'select' && <option value="">{t('cpq.specifications.noDefault', 'No default')}</option>}
|
|
1077
|
+
{editingOptions
|
|
1078
|
+
.filter((o) => o.value.trim() !== '')
|
|
1079
|
+
.map((o) => (
|
|
1080
|
+
<option key={o.value} value={o.value}>
|
|
1081
|
+
{o.label || o.value}
|
|
1082
|
+
</option>
|
|
1083
|
+
))}
|
|
1084
|
+
</select>
|
|
1085
|
+
{editingAttr.attributeType === 'multi-select' && (
|
|
1086
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
1087
|
+
{t('cpq.specifications.multiSelectDefaultHint', 'Ctrl/Cmd-click to select several, or deselect all to clear the default.')}
|
|
1088
|
+
</p>
|
|
1089
|
+
)}
|
|
1090
|
+
</>
|
|
1091
|
+
) : (
|
|
1092
|
+
<input
|
|
1093
|
+
type="text"
|
|
1094
|
+
value={editingAttr.defaultValue == null ? '' : String(editingAttr.defaultValue)}
|
|
1095
|
+
onChange={(e) => setEditingAttr({ ...editingAttr, defaultValue: e.target.value === '' ? null : e.target.value })}
|
|
1096
|
+
placeholder={t('cpq.specifications.noDefault', 'No default')}
|
|
1097
|
+
className="w-full rounded-md border px-2 py-1.5 text-sm"
|
|
1098
|
+
/>
|
|
1099
|
+
)}
|
|
1100
|
+
</div>
|
|
1101
|
+
)}
|
|
1030
1102
|
{editingAttr.attributeType === 'reference' && (
|
|
1031
1103
|
<>
|
|
1032
1104
|
<div>
|
package/src/modules/cpq/cli.ts
CHANGED
|
@@ -1,10 +1,89 @@
|
|
|
1
1
|
import type { ModuleCli } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { seedCpqDemo } from './lib/seeds/demo'
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `mercato cpq seed [--reset] [--tenant <id>] [--org <id>]`
|
|
8
|
+
*
|
|
9
|
+
* Loads a minimal, neutral CPQ test catalog (product + specification +
|
|
10
|
+
* offering + charge) into a single tenant. With no `--tenant`/`--org` it
|
|
11
|
+
* resolves the first (primary) organization created by `mercato init`.
|
|
12
|
+
* `--reset` clears the seed's own rows first.
|
|
13
|
+
*
|
|
14
|
+
* For rich, vertical demo catalogs use `mercato demo_tenants seed --all`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function parseArgs(rest: string[]): Record<string, string | boolean> {
|
|
18
|
+
const out: Record<string, string | boolean> = {}
|
|
19
|
+
for (let i = 0; i < rest.length; i++) {
|
|
20
|
+
const token = rest[i]
|
|
21
|
+
if (!token || !token.startsWith('--')) continue
|
|
22
|
+
const key = token.slice(2)
|
|
23
|
+
const next = rest[i + 1]
|
|
24
|
+
if (next === undefined || next.startsWith('--')) out[key] = true
|
|
25
|
+
else {
|
|
26
|
+
out[key] = next
|
|
27
|
+
i++
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function resolveScope(
|
|
34
|
+
em: EntityManager,
|
|
35
|
+
args: Record<string, string | boolean>,
|
|
36
|
+
): Promise<{ tenantId: string; organizationId: string } | null> {
|
|
37
|
+
const orgArg = typeof args.org === 'string' ? args.org : undefined
|
|
38
|
+
const tenantArg = typeof args.tenant === 'string' ? args.tenant : undefined
|
|
39
|
+
|
|
40
|
+
const { Organization } = await import('@open-mercato/core/modules/directory/data/entities')
|
|
41
|
+
|
|
42
|
+
if (orgArg) {
|
|
43
|
+
const org = await em.findOne(Organization, { id: orgArg } as never)
|
|
44
|
+
if (!org) return null
|
|
45
|
+
return { organizationId: org.id, tenantId: tenantArg ?? org.tenant.id }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const orgs = await em.find(
|
|
49
|
+
Organization,
|
|
50
|
+
{ isActive: true } as never,
|
|
51
|
+
{ orderBy: { createdAt: 'asc' } as never, limit: 5 },
|
|
52
|
+
)
|
|
53
|
+
if (orgs.length === 0) return null
|
|
54
|
+
const org = orgs[0]
|
|
55
|
+
if (orgs.length > 1) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`[cpq seed] Multiple organizations found — defaulting to "${org.name}" (${org.id}). ` +
|
|
58
|
+
`Pass --org <id> to target another.`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
return { organizationId: org.id, tenantId: tenantArg ?? org.tenant.id }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const seed: ModuleCli = {
|
|
4
65
|
command: 'seed',
|
|
5
|
-
async run() {
|
|
6
|
-
|
|
66
|
+
async run(rest) {
|
|
67
|
+
const args = parseArgs(rest)
|
|
68
|
+
const reset = Boolean(args.reset)
|
|
69
|
+
|
|
70
|
+
const container = await createRequestContainer()
|
|
71
|
+
const em = container.resolve<EntityManager>('em')
|
|
72
|
+
|
|
73
|
+
const scope = await resolveScope(em, args)
|
|
74
|
+
if (!scope) {
|
|
75
|
+
console.error('[cpq seed] No organization found. Run "yarn initialize" first, or pass --org <id>.')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const res = await seedCpqDemo(em, scope, { reset })
|
|
80
|
+
console.log(
|
|
81
|
+
`[cpq seed] tenant=${scope.tenantId} org=${scope.organizationId}${reset ? ' (reset)' : ''}\n` +
|
|
82
|
+
` ✓ products +${res.productsCreated}, specs +${res.specsCreated}, ` +
|
|
83
|
+
`offerings +${res.offeringsCreated}, charges +${res.chargesCreated} (existing rows left untouched)\n` +
|
|
84
|
+
` → Backend · CPQ · Product Specifications / Product Offerings.`,
|
|
85
|
+
)
|
|
7
86
|
},
|
|
8
87
|
}
|
|
9
88
|
|
|
10
|
-
export default [
|
|
89
|
+
export default [seed]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { normalizeChargePricing } from '../charge-pricing'
|
|
2
|
+
|
|
3
|
+
// Pins the documented decision table for the legacy-disambiguation logic.
|
|
4
|
+
describe('normalizeChargePricing', () => {
|
|
5
|
+
it('legacy flat without a table → fixed / flat', () => {
|
|
6
|
+
expect(normalizeChargePricing({ pricingMethod: 'flat' })).toEqual({ model: 'flat', source: 'fixed' })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('legacy flat with a pricingTableId → table / flat (preserves calculateFlat-via-table)', () => {
|
|
10
|
+
expect(normalizeChargePricing({ pricingMethod: 'flat', pricingTableId: 't1' })).toEqual({ model: 'flat', source: 'table' })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('legacy per_unit → table / per_unit', () => {
|
|
14
|
+
expect(normalizeChargePricing({ pricingMethod: 'per_unit' })).toEqual({ model: 'per_unit', source: 'table' })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('legacy tiered → table / tiered', () => {
|
|
18
|
+
expect(normalizeChargePricing({ pricingMethod: 'tiered' })).toEqual({ model: 'tiered', source: 'table' })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('legacy fixed → fixed / flat', () => {
|
|
22
|
+
expect(normalizeChargePricing({ pricingMethod: 'fixed' })).toEqual({ model: 'flat', source: 'fixed' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('new split: chargeModel=volume + pricingMethod=table → table / volume', () => {
|
|
26
|
+
expect(normalizeChargePricing({ chargeModel: 'volume', pricingMethod: 'table' })).toEqual({ model: 'volume', source: 'table' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('new split: chargeModel=per_unit + pricingMethod=fixed → fixed / per_unit', () => {
|
|
30
|
+
expect(normalizeChargePricing({ chargeModel: 'per_unit', pricingMethod: 'fixed' })).toEqual({ model: 'per_unit', source: 'fixed' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('explicit chargeModel wins over a legacy pricingMethod value', () => {
|
|
34
|
+
expect(normalizeChargePricing({ chargeModel: 'volume', pricingMethod: 'tiered' })).toEqual({ model: 'volume', source: 'table' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('chargeModel only (no pricingMethod), pricingTableId set → table / <model>', () => {
|
|
38
|
+
expect(normalizeChargePricing({ chargeModel: 'volume', pricingTableId: 't1' })).toEqual({ model: 'volume', source: 'table' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('all empty / null → fixed / flat (default)', () => {
|
|
42
|
+
expect(normalizeChargePricing({})).toEqual({ model: 'flat', source: 'fixed' })
|
|
43
|
+
expect(normalizeChargePricing({ chargeModel: null, pricingMethod: null })).toEqual({ model: 'flat', source: 'fixed' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('is case-insensitive on both axes', () => {
|
|
47
|
+
expect(normalizeChargePricing({ chargeModel: 'TIERED', pricingMethod: 'TABLE' })).toEqual({ model: 'tiered', source: 'table' })
|
|
48
|
+
})
|
|
49
|
+
})
|