Explorar el Código

Add min_price discount rule as a net-price floor backstop

Clamps any accumulated discount so post-discount net price never drops
below rules.min_price.min_net (default $52), across whatever combination
of discount rules produced it. Wired last in the this_month pipeline.
ZiB hace 1 mes
padre
commit
243e6b82a0

+ 11 - 0
room_cal/_utils.py

@@ -290,12 +290,22 @@ class FireRule:
     )
     )
 
 
 
 
+@dataclass
+class MinPriceRule:
+  min_net: int = 52
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'MinPriceRule':
+    return cls(min_net=d.get('min_net', 52))
+
+
 @dataclass
 @dataclass
 class RulesConfig:
 class RulesConfig:
   future_block: FutureBlockRule = field(default_factory=FutureBlockRule)
   future_block: FutureBlockRule = field(default_factory=FutureBlockRule)
   short_notice: ShortNoticeRule = field(default_factory=ShortNoticeRule)
   short_notice: ShortNoticeRule = field(default_factory=ShortNoticeRule)
   fill_month: FillMonthRule = field(default_factory=FillMonthRule)
   fill_month: FillMonthRule = field(default_factory=FillMonthRule)
   fire: FireRule = field(default_factory=FireRule)
   fire: FireRule = field(default_factory=FireRule)
+  min_price: MinPriceRule = field(default_factory=MinPriceRule)
   pipelines: dict[str, PipelineConfig] = field(default_factory=dict)
   pipelines: dict[str, PipelineConfig] = field(default_factory=dict)
 
 
   @classmethod
   @classmethod
@@ -305,6 +315,7 @@ class RulesConfig:
       short_notice=ShortNoticeRule.from_dict(d.get('short_notice', {})),
       short_notice=ShortNoticeRule.from_dict(d.get('short_notice', {})),
       fill_month=FillMonthRule.from_dict(d.get('fill_month', {})),
       fill_month=FillMonthRule.from_dict(d.get('fill_month', {})),
       fire=FireRule.from_dict(d.get('fire', {})),
       fire=FireRule.from_dict(d.get('fire', {})),
+      min_price=MinPriceRule.from_dict(d.get('min_price', {})),
       pipelines={k: PipelineConfig.from_dict(v) for k, v in d.get('pipelines', {}).items()},
       pipelines={k: PipelineConfig.from_dict(v) for k, v in d.get('pipelines', {}).items()},
     )
     )
 
 

+ 5 - 1
room_cal/config-all.json

@@ -18,13 +18,17 @@
       "day_after": 222,
       "day_after": 222,
       "feed_name": "Fire"
       "feed_name": "Fire"
     },
     },
+    "min_price": {
+      "min_net": 52
+    },
     "pipelines": {
     "pipelines": {
       "this_month": {
       "this_month": {
         "type": "discount",
         "type": "discount",
         "rules": [
         "rules": [
           "short_notice",
           "short_notice",
           "fill_month",
           "fill_month",
-          "no_single_nights"
+          "no_single_nights",
+          "min_price"
         ]
         ]
       },
       },
       "future_block": {
       "future_block": {

+ 1 - 1
room_cal/config.json

@@ -38,7 +38,7 @@
       "feed_name": "Fire"
       "feed_name": "Fire"
     },
     },
     "pipelines": {
     "pipelines": {
-      "this_month": {"type": "discount", "rules": ["short_notice", "fill_month", "no_single_nights"]},
+      "this_month": {"type": "discount", "rules": ["short_notice", "fill_month", "no_single_nights", "min_price"]},
       "future_block":  {"type": "closed",   "rules": ["future_block"]},
       "future_block":  {"type": "closed",   "rules": ["future_block"]},
       "special_events": {"type": "price",    "rules": ["fire"]}
       "special_events": {"type": "price",    "rules": ["fire"]}
     }
     }

+ 46 - 0
room_cal/rules/discount/min_price.py

@@ -0,0 +1,46 @@
+"""Clamp discounts so the effective (post-discount) price never drops below a floor.
+
+Transformer rule: receives an accumulated DiscountList and reduces (never
+zeroes-and-drops on its own — a discount that clamps to 0 is simply omitted)
+any discount whose resulting net price would fall below rules.min_price.min_net,
+using each date's current base price. Dates whose base price is already at or
+below the floor lose their discount entirely, since no discount can apply there
+without breaching the floor.
+"""
+import dataclasses
+from datetime import date
+
+from room_cal._utils import get_config
+from room_cal.discount import DiscountList, DiscountType
+from room_cal.pricing import CalPrice
+
+RULE_NAME = 'min_price'
+
+
+async def run(start_date: date, end_date: date, data: DiscountList) -> DiscountList:
+  if not data:
+    return {}
+
+  min_net = get_config().rules.min_price.min_net
+  dates = sorted(data)
+  prices = await CalPrice().get(dates[0], dates[-1])
+
+  result: DiscountList = {}
+  for d, rec in data.items():
+    base = prices[str(d)].base
+    headroom = base - min_net
+    if headroom <= 0:
+      continue
+
+    if rec.type == DiscountType.FLAT:
+      if rec.value > headroom:
+        rec = dataclasses.replace(rec, value=int(headroom))
+    else:
+      max_pct = int(headroom / base * 100)
+      if rec.value > max_pct:
+        rec = dataclasses.replace(rec, value=max_pct)
+
+    if rec.value > 0:
+      result[d] = rec
+
+  return result

+ 69 - 0
tests/test_min_price_rule.py

@@ -0,0 +1,69 @@
+import pytest
+from datetime import date
+from types import SimpleNamespace
+
+from room_cal.discount import DiscountData, DiscountType
+from room_cal.pricing import CalPrice
+from room_cal.pricing.price_calendar import PriceData
+from room_cal.rules.discount.min_price import RULE_NAME, run
+
+
+def _stub_prices(monkeypatch, bases: dict[date, int]):
+  async def _get(self, start_date, end_date):
+    return {str(d): PriceData(base=base, net=base) for d, base in bases.items()}
+  monkeypatch.setattr(CalPrice, 'get', _get)
+
+
+def _stub_config(monkeypatch, min_net: int):
+  cfg = SimpleNamespace(rules=SimpleNamespace(min_price=SimpleNamespace(min_net=min_net)))
+  monkeypatch.setattr('room_cal.rules.discount.min_price.get_config', lambda: cfg)
+
+
+def test_rule_registered():
+  assert RULE_NAME == 'min_price'
+
+
+@pytest.mark.asyncio
+async def test_empty_input_returns_empty():
+  assert await run(date(2026, 1, 1), date(2026, 1, 1), {}) == {}
+
+
+@pytest.mark.asyncio
+async def test_percent_discount_clamped_to_floor(monkeypatch):
+  d = date(2026, 1, 10)
+  _stub_prices(monkeypatch, {d: 100})
+  _stub_config(monkeypatch, 52)
+  data = {d: DiscountData(type=DiscountType.PERCENT, value=60)}
+  result = await run(d, d, data)
+  # headroom = 100 - 52 = 48 -> max_pct = 48%
+  assert result[d].value == 48
+
+
+@pytest.mark.asyncio
+async def test_flat_discount_clamped_to_floor(monkeypatch):
+  d = date(2026, 1, 10)
+  _stub_prices(monkeypatch, {d: 100})
+  _stub_config(monkeypatch, 52)
+  data = {d: DiscountData(type=DiscountType.FLAT, value=60)}
+  result = await run(d, d, data)
+  assert result[d].value == 48
+
+
+@pytest.mark.asyncio
+async def test_discount_under_floor_passes_through(monkeypatch):
+  d = date(2026, 1, 10)
+  _stub_prices(monkeypatch, {d: 100})
+  _stub_config(monkeypatch, 52)
+  data = {d: DiscountData(type=DiscountType.PERCENT, value=30)}
+  result = await run(d, d, data)
+  assert result[d].value == 30
+
+
+@pytest.mark.asyncio
+async def test_base_at_or_below_floor_drops_discount(monkeypatch):
+  d = date(2026, 1, 10)
+  _stub_prices(monkeypatch, {d: 50})
+  _stub_config(monkeypatch, 52)
+  data = {d: DiscountData(type=DiscountType.PERCENT, value=10)}
+  result = await run(d, d, data)
+  assert d not in result