feat: explain auto price reduction decisions and traces (#826)

This commit is contained in:
Jens
2026-02-16 15:52:24 +01:00
committed by GitHub
parent b6cf0eea93
commit 55777710e8
6 changed files with 396 additions and 220 deletions

View File

@@ -111,6 +111,10 @@ When `auto_price_reduction.enabled` is set to `true`, the bot lowers the configu
**Note:** `repost_count` and price reduction counters are only incremented and persisted after a successful publish. Failed publish attempts do not advance the counters. **Note:** `repost_count` and price reduction counters are only incremented and persisted after a successful publish. Failed publish attempts do not advance the counters.
When automatic price reduction is enabled, each `publish` run logs a concise decision line that states whether the reduction was applied or skipped and why (repost delay/day delay), including the earliest next eligible reduction condition.
If you run with `-v` / `--verbose`, the bot additionally logs the full cycle-by-cycle calculation trace (base price, reduction value, rounded step result, and floor clamp).
```yaml ```yaml
auto_price_reduction: auto_price_reduction:
enabled: # true or false to enable automatic price reduction on reposts (default: false) enabled: # true or false to enable automatic price reduction on reposts (default: false)
@@ -163,6 +167,12 @@ This posts the ad at 150 € the first time, then 135 € (15 €), 120 € (
The `delay_days` parameter counts complete 24-hour periods (whole days) since the ad was published. For example, if `delay_days: 7` and the ad was published 6 days and 23 hours ago, the reduction will not yet apply. This ensures predictable behavior and avoids partial-day ambiguity. The `delay_days` parameter counts complete 24-hour periods (whole days) since the ad was published. For example, if `delay_days: 7` and the ad was published 6 days and 23 hours ago, the reduction will not yet apply. This ensures predictable behavior and avoids partial-day ambiguity.
Combined timeline example: with `republication_interval: 3`, `delay_reposts: 1`, and `delay_days: 2`, the first reduction is typically applied on the third publish cycle (around day 8 in a steady schedule, because due ads are republished after more than 3 full days):
- day 0: first publish, no reduction
- day 4: second publish, still waiting for repost delay
- day 8: third publish, first reduction can apply
Set `auto_price_reduction.enabled: false` (or omit the entire `auto_price_reduction` section) to keep the existing behavior—prices stay fixed and `repost_count` only acts as tracked metadata for future changes. Set `auto_price_reduction.enabled: false` (or omit the entire `auto_price_reduction` section) to keep the existing behavior—prices stay fixed and `repost_count` only acts as tracked metadata for future changes.
You can configure `auto_price_reduction` once under `ad_defaults` in `config.yaml`. The `min_price` can be set there or overridden per ad file as needed. You can configure `auto_price_reduction` once under `ad_defaults` in `config.yaml`. The `min_price` can be set there or overridden per ad file as needed.

View File

@@ -104,6 +104,13 @@ ad_defaults:
republication_interval: 7 # every X days ads should be re-published republication_interval: 7 # every X days ads should be re-published
``` ```
- `ad_defaults.republication_interval` controls when ads become due for republishing.
- Automatic price reductions (including `delay_reposts` and `delay_days`) are evaluated only during `publish` runs.
- Reductions do not run in the background between runs, and `update` does not evaluate or apply reductions.
- When auto price reduction is enabled, each `publish` run logs the reduction decision.
- `-v/--verbose` adds a detailed reduction calculation trace.
- For full behavior and examples (including timeline examples), see [AD_CONFIGURATION.md](./AD_CONFIGURATION.md).
> **Tip:** For current defaults of all timeout and diagnostic settings, run `kleinanzeigen-bot create-config` or see the [JSON schema](https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json). > **Tip:** For current defaults of all timeout and diagnostic settings, run `kleinanzeigen-bot create-config` or see the [JSON schema](https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json).
### categories ### categories

View File

@@ -16,7 +16,7 @@ from wcmatch import glob
from . import extract, resources from . import extract, resources
from ._version import __version__ from ._version import __version__
from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price, calculate_auto_price_with_trace
from .model.config_model import Config from .model.config_model import Config
from .update_checker import UpdateChecker from .update_checker import UpdateChecker
from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths
@@ -48,23 +48,25 @@ class LoginState(enum.Enum):
UNKNOWN = enum.auto() UNKNOWN = enum.auto()
def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool: def _repost_cycle_ready(
ad_cfg:Ad,
ad_file_relative:str,
repost_state:tuple[int, int, int, int] | None = None,
) -> bool:
""" """
Check if the repost cycle delay has been satisfied. Check if the repost cycle delay has been satisfied.
:param ad_cfg: The ad configuration :param ad_cfg: The ad configuration
:param ad_file_relative: Relative path to the ad file for logging :param ad_file_relative: Relative path to the ad file for logging
:param repost_state: Optional precomputed repost-delay state tuple
:return: True if ready to apply price reduction, False otherwise :return: True if ready to apply price reduction, False otherwise
""" """
total_reposts = ad_cfg.repost_count or 0 total_reposts, delay_reposts, applied_cycles, eligible_cycles = repost_state or _repost_delay_state(ad_cfg)
delay_reposts = ad_cfg.auto_price_reduction.delay_reposts
applied_cycles = ad_cfg.price_reduction_count or 0
eligible_cycles = max(total_reposts - delay_reposts, 0)
if total_reposts <= delay_reposts: if total_reposts <= delay_reposts:
remaining = (delay_reposts + 1) - total_reposts remaining = (delay_reposts + 1) - total_reposts
LOG.info( LOG.info(
_("Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)"), "Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)",
ad_file_relative, ad_file_relative,
max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold
total_reposts, total_reposts,
@@ -74,39 +76,78 @@ def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool:
if eligible_cycles <= applied_cycles: if eligible_cycles <= applied_cycles:
LOG.debug( LOG.debug(
_("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts"), ad_file_relative, applied_cycles, eligible_cycles "Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts", ad_file_relative, applied_cycles, eligible_cycles
) )
return False return False
return True return True
def _day_delay_elapsed(ad_cfg:Ad, ad_file_relative:str) -> bool: def _day_delay_elapsed(
ad_cfg:Ad,
ad_file_relative:str,
day_delay_state:tuple[bool, int | None, datetime | None] | None = None,
) -> bool:
""" """
Check if the day delay has elapsed since the ad was last published. Check if the day delay has elapsed since the ad was last published.
:param ad_cfg: The ad configuration :param ad_cfg: The ad configuration
:param ad_file_relative: Relative path to the ad file for logging :param ad_file_relative: Relative path to the ad file for logging
:param day_delay_state: Optional precomputed day-delay state tuple
:return: True if the delay has elapsed, False otherwise :return: True if the delay has elapsed, False otherwise
""" """
delay_days = ad_cfg.auto_price_reduction.delay_days delay_days = ad_cfg.auto_price_reduction.delay_days
ready, elapsed_days, reference = day_delay_state or _day_delay_state(ad_cfg)
if delay_days == 0: if delay_days == 0:
return True return True
reference = ad_cfg.updated_on or ad_cfg.created_on
if not reference: if not reference:
LOG.info("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing", ad_file_relative, delay_days) LOG.info("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing", ad_file_relative, delay_days)
return False return False
if not ready and elapsed_days is not None:
LOG.info("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)", ad_file_relative, delay_days, elapsed_days)
return False
return True
def _repost_delay_state(ad_cfg:Ad) -> tuple[int, int, int, int]:
"""Return repost-delay state tuple.
Returns:
tuple[int, int, int, int]:
(total_reposts, delay_reposts, applied_cycles, eligible_cycles)
"""
total_reposts = ad_cfg.repost_count or 0
delay_reposts = ad_cfg.auto_price_reduction.delay_reposts
applied_cycles = ad_cfg.price_reduction_count or 0
eligible_cycles = max(total_reposts - delay_reposts, 0)
return total_reposts, delay_reposts, applied_cycles, eligible_cycles
def _day_delay_state(ad_cfg:Ad) -> tuple[bool, int | None, datetime | None]:
"""Return day-delay state tuple.
Returns:
tuple[bool, int | None, datetime | None]:
(ready_flag, elapsed_days_or_none, reference_timestamp_or_none)
"""
delay_days = ad_cfg.auto_price_reduction.delay_days
# Use getattr to support lightweight test doubles without these attributes.
reference = getattr(ad_cfg, "updated_on", None) or getattr(ad_cfg, "created_on", None)
if delay_days == 0:
return True, 0, reference
if not reference:
return False, None, None
# Note: .days truncates to whole days (e.g., 1.9 days -> 1 day) # Note: .days truncates to whole days (e.g., 1.9 days -> 1 day)
# This is intentional: delays count complete 24-hour periods since publish # This is intentional: delays count complete 24-hour periods since publish
# Both misc.now() and stored timestamps use UTC (via misc.now()), ensuring consistent calculations # Both misc.now() and stored timestamps use UTC (via misc.now()), ensuring consistent calculations
elapsed_days = (misc.now() - reference).days elapsed_days = (misc.now() - reference).days
if elapsed_days < delay_days: return elapsed_days >= delay_days, elapsed_days, reference
LOG.info("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)", ad_file_relative, delay_days, elapsed_days)
return False
return True
def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None:
@@ -132,15 +173,80 @@ def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_r
LOG.warning("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.", ad_file_relative, base_price) LOG.warning("Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.", ad_file_relative, base_price)
return return
if not _repost_cycle_ready(ad_cfg, ad_file_relative): repost_state = _repost_delay_state(ad_cfg)
day_delay_state = _day_delay_state(ad_cfg)
total_reposts, delay_reposts, applied_cycles, eligible_cycles = repost_state
_, elapsed_days, reference = day_delay_state
delay_days = ad_cfg.auto_price_reduction.delay_days
elapsed_display = "missing" if elapsed_days is None else str(elapsed_days)
reference_display = "missing" if reference is None else reference.isoformat(timespec = "seconds")
if not _repost_cycle_ready(ad_cfg, ad_file_relative, repost_state = repost_state):
next_repost = delay_reposts + 1 if total_reposts <= delay_reposts else delay_reposts + applied_cycles + 1
LOG.info(
"Auto price reduction decision for [%s]: skipped (repost delay). next reduction earliest at repost >= %s and day delay %s/%s days."
" repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s",
ad_file_relative,
next_repost,
elapsed_display,
delay_days,
total_reposts,
eligible_cycles,
applied_cycles,
reference_display,
)
return return
if not _day_delay_elapsed(ad_cfg, ad_file_relative): if not _day_delay_elapsed(ad_cfg, ad_file_relative, day_delay_state = day_delay_state):
LOG.info(
"Auto price reduction decision for [%s]: skipped (day delay). next reduction earliest when elapsed_days >= %s."
" elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s",
ad_file_relative,
delay_days,
elapsed_display,
total_reposts,
eligible_cycles,
applied_cycles,
reference_display,
)
return return
applied_cycles = ad_cfg.price_reduction_count or 0 LOG.info(
"Auto price reduction decision for [%s]: applying now (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s).",
ad_file_relative,
eligible_cycles,
applied_cycles,
elapsed_display,
delay_days,
)
next_cycle = applied_cycles + 1 next_cycle = applied_cycles + 1
if loggers.is_debug(LOG):
effective_price, reduction_steps, price_floor = calculate_auto_price_with_trace(
base_price = base_price,
auto_price_reduction = ad_cfg.auto_price_reduction,
target_reduction_cycle = next_cycle,
)
LOG.debug(
"Auto price reduction trace for [%s]: strategy=%s amount=%s floor=%s target_cycle=%s base_price=%s",
ad_file_relative,
ad_cfg.auto_price_reduction.strategy,
ad_cfg.auto_price_reduction.amount,
price_floor,
next_cycle,
base_price,
)
for step in reduction_steps:
LOG.debug(
" -> cycle=%s before=%s reduction=%s after_rounding=%s floor_applied=%s",
step.cycle,
step.price_before,
step.reduction_value,
step.price_after_rounding,
step.floor_applied,
)
else:
effective_price = calculate_auto_price(base_price = base_price, auto_price_reduction = ad_cfg.auto_price_reduction, target_reduction_cycle = next_cycle) effective_price = calculate_auto_price(base_price = base_price, auto_price_reduction = ad_cfg.auto_price_reduction, target_reduction_cycle = next_cycle)
if effective_price is None: if effective_price is None:
@@ -604,10 +710,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
dicts.save_commented_model( dicts.save_commented_model(
self.config_file_path, self.config_file_path,
default_config, default_config,
header = ( header = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json",
"# yaml-language-server: "
"$schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json"
),
exclude = { exclude = {
"ad_defaults": {"description"}, "ad_defaults": {"description"},
}, },

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib, json # isort: skip import hashlib, json # isort: skip
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime # noqa: TC003 Move import into a type-checking block from datetime import datetime # noqa: TC003 Move import into a type-checking block
from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal
from gettext import gettext as _ from gettext import gettext as _
@@ -22,6 +23,17 @@ MAX_DESCRIPTION_LENGTH:Final[int] = 4000
EURO_PRECISION:Final[Decimal] = Decimal("1") EURO_PRECISION:Final[Decimal] = Decimal("1")
@dataclass(frozen = True)
class PriceReductionStep:
"""Single reduction step with before/after values and floor clamp state."""
cycle:int
price_before:Decimal
reduction_value:Decimal
price_after_rounding:Decimal
floor_applied:bool
def _OPTIONAL() -> Any: def _OPTIONAL() -> Any:
return Field(default = None) return Field(default = None)
@@ -65,10 +77,7 @@ def _validate_shipping_option_item(v:str) -> str:
ShippingOption = Annotated[str, AfterValidator(_validate_shipping_option_item)] ShippingOption = Annotated[str, AfterValidator(_validate_shipping_option_item)]
def _validate_auto_price_reduction_constraints( def _validate_auto_price_reduction_constraints(price:int | None, auto_price_reduction:AutoPriceReductionConfig | dict[str, Any] | None) -> None:
price:int | None,
auto_price_reduction:AutoPriceReductionConfig | dict[str, Any] | None
) -> None:
""" """
Validate auto_price_reduction configuration constraints. Validate auto_price_reduction configuration constraints.
@@ -115,20 +124,9 @@ class AdPartial(ContextualModel):
special_attributes:dict[str, str] | None = _OPTIONAL() special_attributes:dict[str, str] | None = _OPTIONAL()
price:int | None = _OPTIONAL() price:int | None = _OPTIONAL()
price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] | None = _OPTIONAL() price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] | None = _OPTIONAL()
auto_price_reduction:AutoPriceReductionConfig | None = Field( auto_price_reduction:AutoPriceReductionConfig | None = Field(default = None, description = "automatic price reduction configuration")
default = None, repost_count:int = Field(default = 0, ge = 0, description = "number of successful publications for this ad (persisted between runs)")
description = "automatic price reduction configuration" price_reduction_count:int = Field(default = 0, ge = 0, description = "internal counter: number of automatic price reductions already applied")
)
repost_count:int = Field(
default = 0,
ge = 0,
description = "number of successful publications for this ad (persisted between runs)"
)
price_reduction_count:int = Field(
default = 0,
ge = 0,
description = "internal counter: number of automatic price reductions already applied"
)
shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] | None = _OPTIONAL() shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] | None = _OPTIONAL()
shipping_costs:float | None = _OPTIONAL() shipping_costs:float | None = _OPTIONAL()
shipping_options:list[ShippingOption] | None = _OPTIONAL() shipping_options:list[ShippingOption] | None = _OPTIONAL()
@@ -205,11 +203,7 @@ class AdPartial(ContextualModel):
if not (isinstance(v, (Mapping, Sequence, set)) and not isinstance(v, (str, bytes)) and len(v) == 0) if not (isinstance(v, (Mapping, Sequence, set)) and not isinstance(v, (str, bytes)) and len(v) == 0)
} }
if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)): if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)):
return [ return [prune(v) for v in obj if not (isinstance(v, (Mapping, Sequence, set)) and not isinstance(v, (str, bytes)) and len(v) == 0)]
prune(v)
for v in obj
if not (isinstance(v, (Mapping, Sequence, set)) and not isinstance(v, (str, bytes)) and len(v) == 0)
]
return obj return obj
pruned = prune(raw) pruned = prune(raw)
@@ -234,7 +228,7 @@ class AdPartial(ContextualModel):
ignore = lambda k, _: k == "description", # ignore legacy global description config ignore = lambda k, _: k == "description", # ignore legacy global description config
override = lambda _, v: ( override = lambda _, v: (
not isinstance(v, list) and (v is None or (isinstance(v, str) and v == "")) # noqa: PLC1901 not isinstance(v, list) and (v is None or (isinstance(v, str) and v == "")) # noqa: PLC1901
) ),
) )
# Ensure internal counters are integers (not user-configurable) # Ensure internal counters are integers (not user-configurable)
if not isinstance(ad_cfg.get("price_reduction_count"), int): if not isinstance(ad_cfg.get("price_reduction_count"), int):
@@ -244,12 +238,9 @@ class AdPartial(ContextualModel):
return Ad.model_validate(ad_cfg) return Ad.model_validate(ad_cfg)
def calculate_auto_price( def _calculate_auto_price_internal(
*, *, base_price:int | float | None, auto_price_reduction:AutoPriceReductionConfig | None, target_reduction_cycle:int, with_trace:bool
base_price:int | float | None, ) -> tuple[int | None, list[PriceReductionStep], Decimal | None]:
auto_price_reduction:AutoPriceReductionConfig | None,
target_reduction_cycle:int
) -> int | None:
""" """
Calculate the effective price for the current run using commercial rounding. Calculate the effective price for the current run using commercial rounding.
@@ -263,15 +254,15 @@ def calculate_auto_price(
Returns an int representing whole euros, or None when base_price is None. Returns an int representing whole euros, or None when base_price is None.
""" """
if base_price is None: if base_price is None:
return None return None, [], None
price = Decimal(str(base_price)) price = Decimal(str(base_price))
if not auto_price_reduction or not auto_price_reduction.enabled or target_reduction_cycle <= 0: if not auto_price_reduction or not auto_price_reduction.enabled or target_reduction_cycle <= 0:
return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)) return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)), [], None
if auto_price_reduction.strategy is None or auto_price_reduction.amount is None: if auto_price_reduction.strategy is None or auto_price_reduction.amount is None:
return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)) return int(price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)), [], None
if auto_price_reduction.min_price is None: if auto_price_reduction.min_price is None:
raise ValueError(_("min_price must be specified when auto_price_reduction is enabled")) raise ValueError(_("min_price must be specified when auto_price_reduction is enabled"))
@@ -279,8 +270,10 @@ def calculate_auto_price(
# Prices are published as whole euros; ensure the configured floor cannot be undercut by int() conversion. # Prices are published as whole euros; ensure the configured floor cannot be undercut by int() conversion.
price_floor = Decimal(str(auto_price_reduction.min_price)).quantize(EURO_PRECISION, rounding = ROUND_CEILING) price_floor = Decimal(str(auto_price_reduction.min_price)).quantize(EURO_PRECISION, rounding = ROUND_CEILING)
repost_cycles = target_reduction_cycle repost_cycles = target_reduction_cycle
steps:list[PriceReductionStep] = []
for _cycle in range(repost_cycles): for cycle_idx in range(repost_cycles):
price_before = price
reduction_value = ( reduction_value = (
price * Decimal(str(auto_price_reduction.amount)) / Decimal("100") price * Decimal(str(auto_price_reduction.amount)) / Decimal("100")
if auto_price_reduction.strategy == "PERCENTAGE" if auto_price_reduction.strategy == "PERCENTAGE"
@@ -289,11 +282,59 @@ def calculate_auto_price(
price -= reduction_value price -= reduction_value
# Commercial rounding: round to full euros after each reduction step # Commercial rounding: round to full euros after each reduction step
price = price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP) price = price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP)
floor_applied = False
if price <= price_floor: if price <= price_floor:
price = price_floor price = price_floor
floor_applied = True
if with_trace:
steps.append(
PriceReductionStep(
cycle = cycle_idx + 1,
price_before = price_before,
reduction_value = reduction_value,
price_after_rounding = price,
floor_applied = floor_applied,
)
)
if floor_applied:
break break
return int(price) return int(price), steps, price_floor
def calculate_auto_price(*, base_price:int | float | None, auto_price_reduction:AutoPriceReductionConfig | None, target_reduction_cycle:int) -> int | None:
return _calculate_auto_price_internal(
base_price = base_price,
auto_price_reduction = auto_price_reduction,
target_reduction_cycle = target_reduction_cycle,
with_trace = False,
)[0]
def calculate_auto_price_with_trace(
*, base_price:int | float | None, auto_price_reduction:AutoPriceReductionConfig | None, target_reduction_cycle:int
) -> tuple[int | None, list[PriceReductionStep], Decimal | None]:
"""Calculate auto price and return a step-by-step reduction trace.
Args:
base_price: starting price before reductions.
auto_price_reduction: reduction configuration (strategy, amount, floor, enabled).
target_reduction_cycle: reduction cycle to compute (0 = no reduction, 1 = first reduction).
Returns:
A tuple of ``(price, steps, price_floor)`` where:
- ``price`` is the computed effective price (``int``) or ``None`` when ``base_price`` is ``None``.
- ``steps`` is a list of ``PriceReductionStep`` entries containing the cycle trace.
- ``price_floor`` is the rounded ``Decimal`` floor used for clamping, or ``None`` when not applicable.
"""
return _calculate_auto_price_internal(
base_price = base_price,
auto_price_reduction = auto_price_reduction,
target_reduction_cycle = target_reduction_cycle,
with_trace = True,
)
# pyright: reportGeneralTypeIssues=false, reportIncompatibleVariableOverride=false # pyright: reportGeneralTypeIssues=false, reportIncompatibleVariableOverride=false

View File

@@ -164,6 +164,11 @@ kleinanzeigen_bot/__init__.py:
apply_auto_price_reduction: apply_auto_price_reduction:
"Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert." "Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert."
"Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten." "Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten."
? "Auto price reduction decision for [%s]: skipped (repost delay). next reduction earliest at repost >= %s and day delay %s/%s days. repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s"
: "Entscheidung automatische Preisreduzierung für [%s]: übersprungen (Repost-Verzögerung). Nächste Reduktion frühestens bei Repost >= %s und Tagesverzögerung %s/%s Tage. repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s"
? "Auto price reduction decision for [%s]: skipped (day delay). next reduction earliest when elapsed_days >= %s. elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s"
: "Entscheidung automatische Preisreduzierung für [%s]: übersprungen (Tagesverzögerung). Nächste Reduktion frühestens bei elapsed_days >= %s. elapsed_days=%s repost_count=%s eligible_cycles=%s applied_cycles=%s reference=%s"
"Auto price reduction decision for [%s]: applying now (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s).": "Entscheidung automatische Preisreduzierung für [%s]: wird jetzt angewendet (eligible_cycles=%s, applied_cycles=%s, elapsed_days=%s/%s)."
"Auto price reduction applied: %s -> %s after %s reduction cycles": "Automatische Preisreduzierung angewendet: %s -> %s nach %s Reduktionszyklen" "Auto price reduction applied: %s -> %s after %s reduction cycles": "Automatische Preisreduzierung angewendet: %s -> %s nach %s Reduktionszyklen"
"Auto price reduction kept price %s after attempting %s reduction cycles": "Automatische Preisreduzierung hat Preis %s beibehalten nach dem Versuch von %s Reduktionszyklen" "Auto price reduction kept price %s after attempting %s reduction cycles": "Automatische Preisreduzierung hat Preis %s beibehalten nach dem Versuch von %s Reduktionszyklen"
_repost_cycle_ready: _repost_cycle_ready:
@@ -648,7 +653,7 @@ kleinanzeigen_bot/model/ad_model.py:
_validate_auto_price_reduction_constraints: _validate_auto_price_reduction_constraints:
"price must be specified when auto_price_reduction is enabled": "price muss angegeben werden, wenn auto_price_reduction aktiviert ist" "price must be specified when auto_price_reduction is enabled": "price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
"min_price must not exceed price": "min_price darf price nicht überschreiten" "min_price must not exceed price": "min_price darf price nicht überschreiten"
calculate_auto_price: _calculate_auto_price_internal:
"min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist" "min_price must be specified when auto_price_reduction is enabled": "min_price muss angegeben werden, wenn auto_price_reduction aktiviert ist"
################################################# #################################################

View File

@@ -30,120 +30,64 @@ def apply_auto_price_reduction() -> _ApplyAutoPriceReduction:
@pytest.mark.unit @pytest.mark.unit
def test_initial_posting_uses_base_price() -> None: def test_initial_posting_uses_base_price() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 0) == 100
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 0
) == 100
@pytest.mark.unit @pytest.mark.unit
def test_auto_price_returns_none_without_base_price() -> None: def test_auto_price_returns_none_without_base_price() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 10) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 10)
assert calculate_auto_price( assert calculate_auto_price(base_price = None, auto_price_reduction = config, target_reduction_cycle = 3) is None
base_price = None,
auto_price_reduction = config,
target_reduction_cycle = 3
) is None
@pytest.mark.unit @pytest.mark.unit
def test_negative_price_reduction_count_is_treated_like_zero() -> None: def test_negative_price_reduction_count_is_treated_like_zero() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = -3) == 100
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = -3
) == 100
@pytest.mark.unit @pytest.mark.unit
def test_missing_price_reduction_returns_base_price() -> None: def test_missing_price_reduction_returns_base_price() -> None:
assert calculate_auto_price( assert calculate_auto_price(base_price = 150, auto_price_reduction = None, target_reduction_cycle = 4) == 150
base_price = 150,
auto_price_reduction = None,
target_reduction_cycle = 4
) == 150
@pytest.mark.unit @pytest.mark.unit
def test_percentage_reduction_on_float_rounds_half_up() -> None: def test_percentage_reduction_on_float_rounds_half_up() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 12.5, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 12.5, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 99.99, auto_price_reduction = config, target_reduction_cycle = 1) == 87
base_price = 99.99,
auto_price_reduction = config,
target_reduction_cycle = 1
) == 87
@pytest.mark.unit @pytest.mark.unit
def test_fixed_reduction_on_float_rounds_half_up() -> None: def test_fixed_reduction_on_float_rounds_half_up() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 12.4, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 12.4, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 80.51, auto_price_reduction = config, target_reduction_cycle = 1) == 68
base_price = 80.51,
auto_price_reduction = config,
target_reduction_cycle = 1
) == 68
@pytest.mark.unit @pytest.mark.unit
def test_percentage_price_reduction_over_time() -> None: def test_percentage_price_reduction_over_time() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 1) == 90
base_price = 100, assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 2) == 81
auto_price_reduction = config, assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 3) == 73
target_reduction_cycle = 1
) == 90
assert calculate_auto_price(
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 2
) == 81
assert calculate_auto_price(
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 3
) == 73
@pytest.mark.unit @pytest.mark.unit
def test_fixed_price_reduction_over_time() -> None: def test_fixed_price_reduction_over_time() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 15, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 15, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 1) == 85
base_price = 100, assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 2) == 70
auto_price_reduction = config, assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 3) == 55
target_reduction_cycle = 1
) == 85
assert calculate_auto_price(
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 2
) == 70
assert calculate_auto_price(
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 3
) == 55
@pytest.mark.unit @pytest.mark.unit
def test_min_price_boundary_is_respected() -> None: def test_min_price_boundary_is_respected() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 20, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 20, min_price = 50)
assert calculate_auto_price( assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 5) == 50
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 5
) == 50
@pytest.mark.unit @pytest.mark.unit
def test_min_price_zero_is_allowed() -> None: def test_min_price_zero_is_allowed() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 5, min_price = 0) config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 5, min_price = 0)
assert calculate_auto_price( assert calculate_auto_price(base_price = 20, auto_price_reduction = config, target_reduction_cycle = 5) == 0
base_price = 20,
auto_price_reduction = config,
target_reduction_cycle = 5
) == 0
@pytest.mark.unit @pytest.mark.unit
@@ -162,26 +106,52 @@ def test_percentage_above_100_raises_error() -> None:
@pytest.mark.unit @pytest.mark.unit
def test_feature_disabled_path_leaves_price_unchanged() -> None: def test_feature_disabled_path_leaves_price_unchanged() -> None:
config = AutoPriceReductionConfig(enabled = False, strategy = "PERCENTAGE", amount = 25, min_price = 50) config = AutoPriceReductionConfig(enabled = False, strategy = "PERCENTAGE", amount = 25, min_price = 50)
price = calculate_auto_price( price = calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 4)
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 4
)
assert price == 100 assert price == 100
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_logs_drop( def test_apply_auto_price_reduction_disabled_emits_no_decision_logs(
caplog:pytest.LogCaptureFixture, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
ad_cfg = SimpleNamespace(
price = 100,
auto_price_reduction = AutoPriceReductionConfig(
enabled = False,
strategy = "PERCENTAGE",
amount = 10,
min_price = 50,
delay_reposts = 0,
delay_days = 0,
),
price_reduction_count = 0,
repost_count = 0,
updated_on = None,
created_on = None,
)
with caplog.at_level(logging.INFO):
apply_auto_price_reduction(ad_cfg, {}, "ad_disabled.yaml")
assert not any("Auto price reduction decision for" in message for message in caplog.messages)
@pytest.mark.unit
def test_apply_auto_price_reduction_logs_drop(caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 200, price = 200,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50, delay_reposts = 0, delay_days = 0 enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 50,
delay_reposts = 0,
delay_days = 0,
), ),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 1 repost_count = 1,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -199,18 +169,17 @@ def test_apply_auto_price_reduction_logs_drop(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_logs_unchanged_price_at_floor( def test_apply_auto_price_reduction_logs_unchanged_price_at_floor(
caplog:pytest.LogCaptureFixture, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
# Test scenario: price has been reduced to just above min_price, # Test scenario: price has been reduced to just above min_price,
# and the next reduction would drop it below, so it gets clamped # and the next reduction would drop it below, so it gets clamped
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 95, price = 95,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 10, min_price = 90, delay_reposts = 0, delay_days = 0),
enabled = True, strategy = "FIXED", amount = 10, min_price = 90, delay_reposts = 0, delay_days = 0
),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 1 repost_count = 1,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -228,17 +197,21 @@ def test_apply_auto_price_reduction_logs_unchanged_price_at_floor(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_warns_when_price_missing( def test_apply_auto_price_reduction_warns_when_price_missing(caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None:
caplog:pytest.LogCaptureFixture,
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = None, price = None,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 10, delay_reposts = 0, delay_days = 0 enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 10,
delay_reposts = 0,
delay_days = 0,
), ),
price_reduction_count = 2, price_reduction_count = 2,
repost_count = 2 repost_count = 2,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -253,16 +226,22 @@ def test_apply_auto_price_reduction_warns_when_price_missing(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_warns_when_min_price_equals_price( def test_apply_auto_price_reduction_warns_when_min_price_equals_price(
caplog:pytest.LogCaptureFixture, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 100, price = 100,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 100, delay_reposts = 0, delay_days = 0 enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 100,
delay_reposts = 0,
delay_days = 0,
), ),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 1 repost_count = 1,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -277,17 +256,21 @@ def test_apply_auto_price_reduction_warns_when_min_price_equals_price(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_respects_repost_delay( def test_apply_auto_price_reduction_respects_repost_delay(caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None:
caplog:pytest.LogCaptureFixture,
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 200, price = 200,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50, delay_reposts = 3, delay_days = 0 enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 50,
delay_reposts = 3,
delay_days = 0,
), ),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 2 repost_count = 2,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -298,19 +281,29 @@ def test_apply_auto_price_reduction_respects_repost_delay(
assert ad_cfg.price == 200 assert ad_cfg.price == 200
delayed_message = _("Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)") % ("ad_delay.yaml", 2, 2, 0) delayed_message = _("Auto price reduction delayed for [%s]: waiting %s more reposts (completed %s, applied %s reductions)") % ("ad_delay.yaml", 2, 2, 0)
assert any(delayed_message in message for message in caplog.messages) assert any(delayed_message in message for message in caplog.messages)
decision_message = (
"Auto price reduction decision for [ad_delay.yaml]: skipped (repost delay). "
"next reduction earliest at repost >= 4 and day delay 0/0 days. repost_count=2 eligible_cycles=0 applied_cycles=0"
)
assert any(message.startswith(decision_message) for message in caplog.messages)
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_after_repost_delay_reduces_once( def test_apply_auto_price_reduction_after_repost_delay_reduces_once(apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None:
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 100, price = 100,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50, delay_reposts = 2, delay_days = 0 enabled = True,
strategy = "PERCENTAGE",
amount = 10,
min_price = 50,
delay_reposts = 2,
delay_days = 0,
), ),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 3 repost_count = 3,
updated_on = None,
created_on = None,
) )
ad_cfg_orig:dict[str, Any] = {} ad_cfg_orig:dict[str, Any] = {}
@@ -324,25 +317,36 @@ def test_apply_auto_price_reduction_after_repost_delay_reduces_once(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_waits_when_reduction_already_applied( def test_apply_auto_price_reduction_waits_when_reduction_already_applied(
caplog:pytest.LogCaptureFixture, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 100, price = 100,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50, delay_reposts = 0, delay_days = 0 enabled = True,
strategy = "PERCENTAGE",
amount = 10,
min_price = 50,
delay_reposts = 0,
delay_days = 0,
), ),
price_reduction_count = 3, price_reduction_count = 3,
repost_count = 3 repost_count = 3,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
with caplog.at_level(logging.DEBUG): with caplog.at_level(logging.DEBUG, logger = "kleinanzeigen_bot"):
apply_auto_price_reduction(ad_cfg, ad_orig, "ad_already.yaml") apply_auto_price_reduction(ad_cfg, ad_orig, "ad_already.yaml")
expected = _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts") % ("ad_already.yaml", 3, 3) expected = _("Auto price reduction already applied for [%s]: %s reductions match %s eligible reposts") % ("ad_already.yaml", 3, 3)
assert any(expected in message for message in caplog.messages) assert any(expected in message for message in caplog.messages)
decision_message = (
"Auto price reduction decision for [ad_already.yaml]: skipped (repost delay). "
"next reduction earliest at repost >= 4 and day delay 0/0 days. repost_count=3 eligible_cycles=3 applied_cycles=3"
)
assert any(message.startswith(decision_message) for message in caplog.messages)
assert ad_cfg.price == 100 assert ad_cfg.price == 100
assert ad_cfg.price_reduction_count == 3 assert ad_cfg.price_reduction_count == 3
assert "price_reduction_count" not in ad_orig assert "price_reduction_count" not in ad_orig
@@ -350,20 +354,23 @@ def test_apply_auto_price_reduction_waits_when_reduction_already_applied(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_respects_day_delay( def test_apply_auto_price_reduction_respects_day_delay(
monkeypatch:pytest.MonkeyPatch, monkeypatch:pytest.MonkeyPatch, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
caplog:pytest.LogCaptureFixture,
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
reference = datetime(2025, 1, 1, tzinfo = timezone.utc) reference = datetime(2025, 1, 1, tzinfo = timezone.utc)
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 150, price = 150,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50, delay_reposts = 0, delay_days = 3 enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 50,
delay_reposts = 0,
delay_days = 3,
), ),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 1, repost_count = 1,
updated_on = reference, updated_on = reference,
created_on = reference created_on = reference,
) )
monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: reference + timedelta(days = 1)) monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: reference + timedelta(days = 1))
@@ -379,20 +386,22 @@ def test_apply_auto_price_reduction_respects_day_delay(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_runs_after_delays( def test_apply_auto_price_reduction_runs_after_delays(monkeypatch:pytest.MonkeyPatch, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None:
monkeypatch:pytest.MonkeyPatch,
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None:
reference = datetime(2025, 1, 1, tzinfo = timezone.utc) reference = datetime(2025, 1, 1, tzinfo = timezone.utc)
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 120, price = 120,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(
enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 60, delay_reposts = 2, delay_days = 3 enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 60,
delay_reposts = 2,
delay_days = 3,
), ),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 3, repost_count = 3,
updated_on = reference - timedelta(days = 5), updated_on = reference - timedelta(days = 5),
created_on = reference - timedelta(days = 10) created_on = reference - timedelta(days = 10),
) )
monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: reference) monkeypatch.setattr("kleinanzeigen_bot.misc.now", lambda: reference)
@@ -405,18 +414,15 @@ def test_apply_auto_price_reduction_runs_after_delays(
@pytest.mark.unit @pytest.mark.unit
def test_apply_auto_price_reduction_delayed_when_timestamp_missing( def test_apply_auto_price_reduction_delayed_when_timestamp_missing(
caplog:pytest.LogCaptureFixture, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 200, price = 200,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 20, min_price = 50, delay_reposts = 0, delay_days = 2),
enabled = True, strategy = "FIXED", amount = 20, min_price = 50, delay_reposts = 0, delay_days = 2
),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 1, repost_count = 1,
updated_on = None, updated_on = None,
created_on = None created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -430,18 +436,17 @@ def test_apply_auto_price_reduction_delayed_when_timestamp_missing(
@pytest.mark.unit @pytest.mark.unit
def test_fractional_reduction_increments_counter_even_when_price_unchanged( def test_fractional_reduction_increments_counter_even_when_price_unchanged(
caplog:pytest.LogCaptureFixture, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction
apply_auto_price_reduction:_ApplyAutoPriceReduction
) -> None: ) -> None:
# Test that small fractional reductions increment the counter even when rounded price doesn't change # Test that small fractional reductions increment the counter even when rounded price doesn't change
# This allows cumulative reductions to eventually show visible effect # This allows cumulative reductions to eventually show visible effect
ad_cfg = SimpleNamespace( ad_cfg = SimpleNamespace(
price = 100, price = 100,
auto_price_reduction = AutoPriceReductionConfig( auto_price_reduction = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 0.3, min_price = 50, delay_reposts = 0, delay_days = 0),
enabled = True, strategy = "FIXED", amount = 0.3, min_price = 50, delay_reposts = 0, delay_days = 0
),
price_reduction_count = 0, price_reduction_count = 0,
repost_count = 1 repost_count = 1,
updated_on = None,
created_on = None,
) )
ad_orig:dict[str, Any] = {} ad_orig:dict[str, Any] = {}
@@ -458,6 +463,31 @@ def test_fractional_reduction_increments_counter_even_when_price_unchanged(
assert "price_reduction_count" not in ad_orig assert "price_reduction_count" not in ad_orig
@pytest.mark.unit
def test_apply_auto_price_reduction_verbose_logs_trace(caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None:
ad_cfg = SimpleNamespace(
price = 200,
auto_price_reduction = AutoPriceReductionConfig(
enabled = True,
strategy = "PERCENTAGE",
amount = 25,
min_price = 50,
delay_reposts = 0,
delay_days = 0,
),
price_reduction_count = 0,
repost_count = 1,
updated_on = None,
created_on = None,
)
with caplog.at_level(logging.DEBUG, logger = "kleinanzeigen_bot"):
apply_auto_price_reduction(ad_cfg, {}, "ad_trace.yaml")
assert any("Auto price reduction trace for [ad_trace.yaml]" in message for message in caplog.messages)
assert any(" -> cycle=1 before=200 reduction=50.0 after_rounding=150 floor_applied=False" in message for message in caplog.messages)
@pytest.mark.unit @pytest.mark.unit
def test_reduction_value_zero_raises_error() -> None: def test_reduction_value_zero_raises_error() -> None:
with pytest.raises(ContextualValidationError, match = "Input should be greater than 0"): with pytest.raises(ContextualValidationError, match = "Input should be greater than 0"):
@@ -473,22 +503,14 @@ def test_reduction_value_negative_raises_error() -> None:
@pytest.mark.unit @pytest.mark.unit
def test_percentage_reduction_100_percent() -> None: def test_percentage_reduction_100_percent() -> None:
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 100, min_price = 0) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 100, min_price = 0)
assert calculate_auto_price( assert calculate_auto_price(base_price = 150, auto_price_reduction = config, target_reduction_cycle = 1) == 0
base_price = 150,
auto_price_reduction = config,
target_reduction_cycle = 1
) == 0
@pytest.mark.unit @pytest.mark.unit
def test_extreme_reduction_cycles() -> None: def test_extreme_reduction_cycles() -> None:
# Test that extreme cycle counts don't cause performance issues or errors # Test that extreme cycle counts don't cause performance issues or errors
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 0) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 0)
result = calculate_auto_price( result = calculate_auto_price(base_price = 1000, auto_price_reduction = config, target_reduction_cycle = 100)
base_price = 1000,
auto_price_reduction = config,
target_reduction_cycle = 100
)
# With commercial rounding (round after each step), price stabilizes at 5 # With commercial rounding (round after each step), price stabilizes at 5
# because 5 * 0.9 = 4.5 rounds back to 5 with ROUND_HALF_UP # because 5 * 0.9 = 4.5 rounds back to 5 with ROUND_HALF_UP
assert result == 5 assert result == 5
@@ -502,11 +524,7 @@ def test_commercial_rounding_each_step() -> None:
# Step 1: 135 * 0.9 = 121.5 → rounds to 122 EUR # Step 1: 135 * 0.9 = 121.5 → rounds to 122 EUR
# Step 2: 122 * 0.9 = 109.8 → rounds to 110 EUR # Step 2: 122 * 0.9 = 109.8 → rounds to 110 EUR
# (Without intermediate rounding, it would be: 135 * 0.9^2 = 109.35 → 109 EUR) # (Without intermediate rounding, it would be: 135 * 0.9^2 = 109.35 → 109 EUR)
result = calculate_auto_price( result = calculate_auto_price(base_price = 135, auto_price_reduction = config, target_reduction_cycle = 2)
base_price = 135,
auto_price_reduction = config,
target_reduction_cycle = 2
)
assert result == 110 # Commercial rounding result assert result == 110 # Commercial rounding result
@@ -514,11 +532,7 @@ def test_commercial_rounding_each_step() -> None:
def test_extreme_reduction_cycles_with_floor() -> None: def test_extreme_reduction_cycles_with_floor() -> None:
# Test that extreme cycles stop at min_price and don't cause issues # Test that extreme cycles stop at min_price and don't cause issues
config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50)
result = calculate_auto_price( result = calculate_auto_price(base_price = 1000, auto_price_reduction = config, target_reduction_cycle = 1000)
base_price = 1000,
auto_price_reduction = config,
target_reduction_cycle = 1000
)
# Should stop at min_price, not go to 0, regardless of cycle count # Should stop at min_price, not go to 0, regardless of cycle count
assert result == 50 assert result == 50
@@ -533,11 +547,7 @@ def test_fractional_min_price_is_rounded_up_with_ceiling() -> None:
# Start at 100, reduce by 10 = 90 # Start at 100, reduce by 10 = 90
# But min_price=90.5 rounds UP to 91 with ROUND_CEILING # But min_price=90.5 rounds UP to 91 with ROUND_CEILING
# So the result should be 91, not 90 # So the result should be 91, not 90
result = calculate_auto_price( result = calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 1)
base_price = 100,
auto_price_reduction = config,
target_reduction_cycle = 1
)
assert result == 91 # Rounded up from 90.5 floor assert result == 91 # Rounded up from 90.5 floor
# Verify with another fractional value # Verify with another fractional value
@@ -545,6 +555,6 @@ def test_fractional_min_price_is_rounded_up_with_ceiling() -> None:
result2 = calculate_auto_price( result2 = calculate_auto_price(
base_price = 60, base_price = 60,
auto_price_reduction = config2, auto_price_reduction = config2,
target_reduction_cycle = 3 # 60 - 5 - 5 - 5 = 45, clamped to ceil(49.1) = 50 target_reduction_cycle = 3, # 60 - 5 - 5 - 5 = 45, clamped to ceil(49.1) = 50
) )
assert result2 == 50 # Rounded up from 49.1 floor assert result2 == 50 # Rounded up from 49.1 floor