hvp-shared 6.46.0 → 6.47.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.
@@ -128,4 +128,29 @@ export declare function calculateRecommendedPricePVP(params: {
128
128
  unitPurchasePriceBI: number;
129
129
  vatSalePercent: number;
130
130
  }): number | null;
131
+ /** Cost-based multiplier for floor price (PHARM, PHARM_FRAC, COST_ML, COST) */
132
+ export declare const MIN_PRICE_COST_FACTOR = 0.25;
133
+ /** Cost-based multiplier for floor price (EXT_COST) */
134
+ export declare const MIN_PRICE_EXT_COST_FACTOR = 1;
135
+ /** Fraction of salePricePVP for recommended minimum price (COMP_SVC) */
136
+ export declare const MIN_PRICE_COMP_SVC_SALE_FRACTION = 0.5;
137
+ export interface FloorPriceResult {
138
+ recommendedMinimumPrice: number | null;
139
+ floorPrice: number | null;
140
+ }
141
+ /**
142
+ * Calculate floor prices (recommended minimum price and absolute floor) for a catalog item.
143
+ *
144
+ * Business rules by PricePolicy:
145
+ * - PHARM, PHARM_FRAC, COST_ML, COST: both = unitCostBI × 0.25 × (1 + VAT/100), round5
146
+ * - EXT_COST: both = unitCostBI × 1.0 × (1 + VAT/100), round5
147
+ * - COMP_SVC: recommendedMinimumPrice = salePricePVP × 0.50 (round5), floorPrice = 0
148
+ * - NONE, SPECIAL, MANUAL: both null
149
+ */
150
+ export declare function calculateFloorPrices(params: {
151
+ pricePolicy: PricePolicy;
152
+ unitPurchasePriceBI: number;
153
+ vatSalePercent: number;
154
+ salePricePVP: number;
155
+ }): FloorPriceResult;
131
156
  export {};
@@ -9,11 +9,12 @@
9
9
  * recommendedPrice = unitCostBI * markupFactor * (1 + vatSale/100)
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.ALLOWED_POLICIES_BY_SECTION = exports.COMP_SVC_SECTIONS = exports.NON_SELLABLE_SECTIONS = exports.NO_FORMULA_POLICIES = exports.AUTO_TIER_POLICIES = exports.AUTO_TIER_RANGES = exports.MARKUP_FACTORS = exports.PRICING_TIER_VALUES = exports.PRICE_POLICY_VALUES = exports.PRICING_TIER_LABELS = exports.PRICE_POLICY_LABELS = exports.PricingTier = exports.PricePolicy = void 0;
12
+ exports.MIN_PRICE_COMP_SVC_SALE_FRACTION = exports.MIN_PRICE_EXT_COST_FACTOR = exports.MIN_PRICE_COST_FACTOR = exports.ALLOWED_POLICIES_BY_SECTION = exports.COMP_SVC_SECTIONS = exports.NON_SELLABLE_SECTIONS = exports.NO_FORMULA_POLICIES = exports.AUTO_TIER_POLICIES = exports.AUTO_TIER_RANGES = exports.MARKUP_FACTORS = exports.PRICING_TIER_VALUES = exports.PRICE_POLICY_VALUES = exports.PRICING_TIER_LABELS = exports.PRICE_POLICY_LABELS = exports.PricingTier = exports.PricePolicy = void 0;
13
13
  exports.getAutoTier = getAutoTier;
14
14
  exports.getMarkupFactor = getMarkupFactor;
15
15
  exports.getDefaultPricePolicy = getDefaultPricePolicy;
16
16
  exports.calculateRecommendedPricePVP = calculateRecommendedPricePVP;
17
+ exports.calculateFloorPrices = calculateFloorPrices;
17
18
  const qvet_catalog_1 = require("./qvet-catalog");
18
19
  // =============================================================================
19
20
  // ENUMS
@@ -214,6 +215,13 @@ function getDefaultPricePolicy(section, saleUnit, conversionFactor) {
214
215
  // Fallback: manual pricing
215
216
  return { policy: PricePolicy.MANUAL, tier: null };
216
217
  }
218
+ // =============================================================================
219
+ // ROUNDING HELPER
220
+ // =============================================================================
221
+ /** Round to nearest multiple of 5 */
222
+ function roundToNearest5(value) {
223
+ return Math.round(value / 5) * 5;
224
+ }
217
225
  /**
218
226
  * Calculate the recommended price PVP (con IVA) for a catalog item.
219
227
  *
@@ -230,6 +238,56 @@ function calculateRecommendedPricePVP(params) {
230
238
  return null;
231
239
  const priceBI = unitPurchasePriceBI * factor;
232
240
  const pricePVP = priceBI * (1 + vatSalePercent / 100);
233
- // Round to nearest multiple of 5
234
- return Math.round(pricePVP / 5) * 5;
241
+ return roundToNearest5(pricePVP);
242
+ }
243
+ // =============================================================================
244
+ // FLOOR PRICE CALCULATION
245
+ // =============================================================================
246
+ /** Cost-based multiplier for floor price (PHARM, PHARM_FRAC, COST_ML, COST) */
247
+ exports.MIN_PRICE_COST_FACTOR = 0.25;
248
+ /** Cost-based multiplier for floor price (EXT_COST) */
249
+ exports.MIN_PRICE_EXT_COST_FACTOR = 1.0;
250
+ /** Fraction of salePricePVP for recommended minimum price (COMP_SVC) */
251
+ exports.MIN_PRICE_COMP_SVC_SALE_FRACTION = 0.50;
252
+ /** Policies that use cost-based floor price (cost × factor × (1+VAT)) */
253
+ const COST_BASED_FLOOR_POLICIES = [
254
+ PricePolicy.PHARM,
255
+ PricePolicy.PHARM_FRAC,
256
+ PricePolicy.COST_ML,
257
+ PricePolicy.COST,
258
+ ];
259
+ /**
260
+ * Calculate floor prices (recommended minimum price and absolute floor) for a catalog item.
261
+ *
262
+ * Business rules by PricePolicy:
263
+ * - PHARM, PHARM_FRAC, COST_ML, COST: both = unitCostBI × 0.25 × (1 + VAT/100), round5
264
+ * - EXT_COST: both = unitCostBI × 1.0 × (1 + VAT/100), round5
265
+ * - COMP_SVC: recommendedMinimumPrice = salePricePVP × 0.50 (round5), floorPrice = 0
266
+ * - NONE, SPECIAL, MANUAL: both null
267
+ */
268
+ function calculateFloorPrices(params) {
269
+ const { pricePolicy, unitPurchasePriceBI, vatSalePercent, salePricePVP } = params;
270
+ // Cost-based policies (PHARM, PHARM_FRAC, COST_ML, COST)
271
+ if (COST_BASED_FLOOR_POLICIES.includes(pricePolicy)) {
272
+ if (unitPurchasePriceBI <= 0)
273
+ return { recommendedMinimumPrice: null, floorPrice: null };
274
+ const price = roundToNearest5(unitPurchasePriceBI * exports.MIN_PRICE_COST_FACTOR * (1 + vatSalePercent / 100));
275
+ return { recommendedMinimumPrice: price, floorPrice: price };
276
+ }
277
+ // External cost services
278
+ if (pricePolicy === PricePolicy.EXT_COST) {
279
+ if (unitPurchasePriceBI <= 0)
280
+ return { recommendedMinimumPrice: null, floorPrice: null };
281
+ const price = roundToNearest5(unitPurchasePriceBI * exports.MIN_PRICE_EXT_COST_FACTOR * (1 + vatSalePercent / 100));
282
+ return { recommendedMinimumPrice: price, floorPrice: price };
283
+ }
284
+ // Internal services (market-study pricing)
285
+ if (pricePolicy === PricePolicy.COMP_SVC) {
286
+ const minPrice = salePricePVP > 0
287
+ ? roundToNearest5(salePricePVP * exports.MIN_PRICE_COMP_SVC_SALE_FRACTION)
288
+ : null;
289
+ return { recommendedMinimumPrice: minPrice, floorPrice: 0 };
290
+ }
291
+ // NONE, SPECIAL, MANUAL — no floor prices
292
+ return { recommendedMinimumPrice: null, floorPrice: null };
235
293
  }
@@ -312,3 +312,85 @@ describe('calculateRecommendedPricePVP', () => {
312
312
  expect(result).toBe(60);
313
313
  });
314
314
  });
315
+ // =============================================================================
316
+ // calculateFloorPrices
317
+ // =============================================================================
318
+ describe('calculateFloorPrices', () => {
319
+ const defaults = { unitPurchasePriceBI: 100, vatSalePercent: 16, salePricePVP: 500 };
320
+ describe('cost-based policies (PHARM, PHARM_FRAC, COST_ML, COST)', () => {
321
+ const costPolicies = [pricing_constants_1.PricePolicy.PHARM, pricing_constants_1.PricePolicy.PHARM_FRAC, pricing_constants_1.PricePolicy.COST_ML, pricing_constants_1.PricePolicy.COST];
322
+ it.each(costPolicies)('%s: both = cost × 0.25 × (1+VAT/100), round5', (policy) => {
323
+ // $100 * 0.25 * 1.16 = $29 → round5 = $30
324
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: policy });
325
+ expect(result.recommendedMinimumPrice).toBe(30);
326
+ expect(result.floorPrice).toBe(30);
327
+ });
328
+ it('should round to nearest 5', () => {
329
+ // $80 * 0.25 * 1.16 = $23.20 → round5 = $25
330
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.PHARM, unitPurchasePriceBI: 80 });
331
+ expect(result.recommendedMinimumPrice).toBe(25);
332
+ expect(result.floorPrice).toBe(25);
333
+ });
334
+ it('should return null when cost is 0', () => {
335
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.PHARM, unitPurchasePriceBI: 0 });
336
+ expect(result.recommendedMinimumPrice).toBeNull();
337
+ expect(result.floorPrice).toBeNull();
338
+ });
339
+ it('should return null when cost is negative', () => {
340
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.COST, unitPurchasePriceBI: -10 });
341
+ expect(result.recommendedMinimumPrice).toBeNull();
342
+ expect(result.floorPrice).toBeNull();
343
+ });
344
+ it('should handle 0% VAT', () => {
345
+ // $100 * 0.25 * 1.0 = $25 → $25
346
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.COST, vatSalePercent: 0 });
347
+ expect(result.recommendedMinimumPrice).toBe(25);
348
+ expect(result.floorPrice).toBe(25);
349
+ });
350
+ });
351
+ describe('EXT_COST', () => {
352
+ it('both = cost × 1.0 × (1+VAT/100), round5', () => {
353
+ // $100 * 1.0 * 1.16 = $116 → round5 = $115
354
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.EXT_COST });
355
+ expect(result.recommendedMinimumPrice).toBe(115);
356
+ expect(result.floorPrice).toBe(115);
357
+ });
358
+ it('should return null when cost is 0', () => {
359
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.EXT_COST, unitPurchasePriceBI: 0 });
360
+ expect(result.recommendedMinimumPrice).toBeNull();
361
+ expect(result.floorPrice).toBeNull();
362
+ });
363
+ it('should round to nearest 5', () => {
364
+ // $200 * 1.0 * 1.16 = $232 → round5 = $230
365
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.EXT_COST, unitPurchasePriceBI: 200 });
366
+ expect(result.recommendedMinimumPrice).toBe(230);
367
+ expect(result.floorPrice).toBe(230);
368
+ });
369
+ });
370
+ describe('COMP_SVC', () => {
371
+ it('recommendedMinimumPrice = salePricePVP × 0.50, floorPrice = 0', () => {
372
+ // $500 * 0.50 = $250 → round5 = $250
373
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.COMP_SVC });
374
+ expect(result.recommendedMinimumPrice).toBe(250);
375
+ expect(result.floorPrice).toBe(0);
376
+ });
377
+ it('should round salePricePVP fraction to nearest 5', () => {
378
+ // $330 * 0.50 = $165 → round5 = $165
379
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.COMP_SVC, salePricePVP: 330 });
380
+ expect(result.recommendedMinimumPrice).toBe(165);
381
+ expect(result.floorPrice).toBe(0);
382
+ });
383
+ it('should return null recommendedMinimumPrice when salePricePVP is 0', () => {
384
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: pricing_constants_1.PricePolicy.COMP_SVC, salePricePVP: 0 });
385
+ expect(result.recommendedMinimumPrice).toBeNull();
386
+ expect(result.floorPrice).toBe(0);
387
+ });
388
+ });
389
+ describe('no-formula policies (NONE, SPECIAL, MANUAL)', () => {
390
+ it.each([pricing_constants_1.PricePolicy.NONE, pricing_constants_1.PricePolicy.SPECIAL, pricing_constants_1.PricePolicy.MANUAL])('%s: both null', (policy) => {
391
+ const result = (0, pricing_constants_1.calculateFloorPrices)({ ...defaults, pricePolicy: policy });
392
+ expect(result.recommendedMinimumPrice).toBeNull();
393
+ expect(result.floorPrice).toBeNull();
394
+ });
395
+ });
396
+ });
@@ -103,6 +103,10 @@ export interface UpdateCatalogItemHvpDataRequest {
103
103
  pricingTier?: PricingTier;
104
104
  /** Recommended price PVP (calculated from policy + tier + cost) */
105
105
  recommendedPrice?: number;
106
+ /** Recommended minimum price PVP (calculated from policy + cost) */
107
+ recommendedMinimumPrice?: number;
108
+ /** Floor price PVP (calculated from policy + cost) */
109
+ floorPrice?: number;
106
110
  /** How stock levels are managed */
107
111
  stockPolicy?: StockPolicy;
108
112
  /** Observations generated by analysis script */
@@ -135,6 +135,10 @@ export interface CatalogItemDetailResponse {
135
135
  * Calculated from: unitCostBI * markupFactor * (1 + VAT/100)
136
136
  */
137
137
  recommendedPrice?: number;
138
+ /** Precio mínimo recomendado PVP (con IVA) — suggested QVET "tarifa mínima" */
139
+ recommendedMinimumPrice?: number;
140
+ /** Precio piso PVP (con IVA) — absolute minimum, never sell below this */
141
+ floorPrice?: number;
138
142
  /** How stock levels are managed */
139
143
  stockPolicy?: StockPolicy;
140
144
  /** Observations generated by analysis script */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hvp-shared",
3
- "version": "6.46.0",
3
+ "version": "6.47.0",
4
4
  "description": "Shared types and utilities for HVP backend and frontend",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",