From 55777710e8a9d307aa42dc0502bc2bc580982b90 Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:52:24 +0100 Subject: [PATCH] feat: explain auto price reduction decisions and traces (#826) --- docs/AD_CONFIGURATION.md | 10 + docs/CONFIGURATION.md | 7 + src/kleinanzeigen_bot/__init__.py | 149 ++++++-- src/kleinanzeigen_bot/model/ad_model.py | 111 ++++-- .../resources/translations.de.yaml | 7 +- tests/unit/test_price_reduction.py | 332 +++++++++--------- 6 files changed, 396 insertions(+), 220 deletions(-) diff --git a/docs/AD_CONFIGURATION.md b/docs/AD_CONFIGURATION.md index b2e0532..d88e6b2 100644 --- a/docs/AD_CONFIGURATION.md +++ b/docs/AD_CONFIGURATION.md @@ -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. +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 auto_price_reduction: 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. +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. 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. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9413218..208f8a0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -104,6 +104,13 @@ ad_defaults: 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). ### categories diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index fa1ba2e..93bf464 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -16,7 +16,7 @@ from wcmatch import glob from . import extract, resources 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 .update_checker import UpdateChecker from .utils import diagnostics, dicts, error_handlers, loggers, misc, xdg_paths @@ -48,23 +48,25 @@ class LoginState(enum.Enum): 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. :param ad_cfg: The ad configuration :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 """ - 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) + total_reposts, delay_reposts, applied_cycles, eligible_cycles = repost_state or _repost_delay_state(ad_cfg) if total_reposts <= delay_reposts: remaining = (delay_reposts + 1) - total_reposts 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, max(remaining, 1), # Clamp to 1 to avoid showing "0 more reposts" when at threshold total_reposts, @@ -74,39 +76,78 @@ def _repost_cycle_ready(ad_cfg:Ad, ad_file_relative:str) -> bool: if eligible_cycles <= applied_cycles: 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 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. :param ad_cfg: The ad configuration :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 """ 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: return True - reference = ad_cfg.updated_on or ad_cfg.created_on if not reference: LOG.info("Auto price reduction delayed for [%s]: waiting %s days but publish timestamp missing", ad_file_relative, delay_days) 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) # 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 elapsed_days = (misc.now() - reference).days - if elapsed_days < delay_days: - LOG.info("Auto price reduction delayed for [%s]: waiting %s days (elapsed %s)", ad_file_relative, delay_days, elapsed_days) - return False - - return True + return elapsed_days >= delay_days, elapsed_days, reference def apply_auto_price_reduction(ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None: @@ -132,16 +173,81 @@ 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) 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 - 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 - 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 - effective_price = calculate_auto_price(base_price = base_price, auto_price_reduction = ad_cfg.auto_price_reduction, target_reduction_cycle = next_cycle) + 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) if effective_price is None: return @@ -604,10 +710,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 dicts.save_commented_model( self.config_file_path, default_config, - header = ( - "# yaml-language-server: " - "$schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json" - ), + header = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json", exclude = { "ad_defaults": {"description"}, }, diff --git a/src/kleinanzeigen_bot/model/ad_model.py b/src/kleinanzeigen_bot/model/ad_model.py index 1ec8b91..612aa70 100644 --- a/src/kleinanzeigen_bot/model/ad_model.py +++ b/src/kleinanzeigen_bot/model/ad_model.py @@ -5,6 +5,7 @@ from __future__ import annotations import hashlib, json # isort: skip from collections.abc import Mapping, Sequence +from dataclasses import dataclass from datetime import datetime # noqa: TC003 Move import into a type-checking block from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal from gettext import gettext as _ @@ -22,6 +23,17 @@ MAX_DESCRIPTION_LENGTH:Final[int] = 4000 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: return Field(default = None) @@ -65,10 +77,7 @@ def _validate_shipping_option_item(v:str) -> str: ShippingOption = Annotated[str, AfterValidator(_validate_shipping_option_item)] -def _validate_auto_price_reduction_constraints( - price:int | None, - auto_price_reduction:AutoPriceReductionConfig | dict[str, Any] | None -) -> None: +def _validate_auto_price_reduction_constraints(price:int | None, auto_price_reduction:AutoPriceReductionConfig | dict[str, Any] | None) -> None: """ Validate auto_price_reduction configuration constraints. @@ -115,20 +124,9 @@ class AdPartial(ContextualModel): special_attributes:dict[str, str] | None = _OPTIONAL() price:int | None = _OPTIONAL() price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] | None = _OPTIONAL() - auto_price_reduction:AutoPriceReductionConfig | None = Field( - default = None, - description = "automatic price reduction configuration" - ) - 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" - ) + auto_price_reduction:AutoPriceReductionConfig | None = Field(default = None, description = "automatic price reduction configuration") + 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_costs:float | 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 isinstance(obj, Sequence) and not isinstance(obj, (str, bytes)): - return [ - prune(v) - for v in obj - if not (isinstance(v, (Mapping, Sequence, set)) and not isinstance(v, (str, bytes)) and len(v) == 0) - ] + return [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 pruned = prune(raw) @@ -234,7 +228,7 @@ class AdPartial(ContextualModel): ignore = lambda k, _: k == "description", # ignore legacy global description config override = lambda _, v: ( not isinstance(v, list) and (v is None or (isinstance(v, str) and v == "")) # noqa: PLC1901 - ) + ), ) # Ensure internal counters are integers (not user-configurable) if not isinstance(ad_cfg.get("price_reduction_count"), int): @@ -244,12 +238,9 @@ class AdPartial(ContextualModel): return Ad.model_validate(ad_cfg) -def calculate_auto_price( - *, - base_price:int | float | None, - auto_price_reduction:AutoPriceReductionConfig | None, - target_reduction_cycle:int -) -> int | None: +def _calculate_auto_price_internal( + *, base_price:int | float | None, auto_price_reduction:AutoPriceReductionConfig | None, target_reduction_cycle:int, with_trace:bool +) -> tuple[int | None, list[PriceReductionStep], Decimal | None]: """ 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. """ if base_price is None: - return None + return None, [], None price = Decimal(str(base_price)) 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: - 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: 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. price_floor = Decimal(str(auto_price_reduction.min_price)).quantize(EURO_PRECISION, rounding = ROUND_CEILING) 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 = ( price * Decimal(str(auto_price_reduction.amount)) / Decimal("100") if auto_price_reduction.strategy == "PERCENTAGE" @@ -289,11 +282,59 @@ def calculate_auto_price( price -= reduction_value # Commercial rounding: round to full euros after each reduction step price = price.quantize(EURO_PRECISION, rounding = ROUND_HALF_UP) + floor_applied = False if 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 - 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 diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 1ec4ef2..927b73c 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -164,6 +164,11 @@ kleinanzeigen_bot/__init__.py: 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 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 kept price %s after attempting %s reduction cycles": "Automatische Preisreduzierung hat Preis %s beibehalten nach dem Versuch von %s Reduktionszyklen" _repost_cycle_ready: @@ -648,7 +653,7 @@ kleinanzeigen_bot/model/ad_model.py: _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" "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" ################################################# diff --git a/tests/unit/test_price_reduction.py b/tests/unit/test_price_reduction.py index dd5e8c4..6f55911 100644 --- a/tests/unit/test_price_reduction.py +++ b/tests/unit/test_price_reduction.py @@ -30,120 +30,64 @@ def apply_auto_price_reduction() -> _ApplyAutoPriceReduction: @pytest.mark.unit def test_initial_posting_uses_base_price() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) - assert calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - target_reduction_cycle = 0 - ) == 100 + assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 0) == 100 @pytest.mark.unit def test_auto_price_returns_none_without_base_price() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 10) - assert calculate_auto_price( - base_price = None, - auto_price_reduction = config, - target_reduction_cycle = 3 - ) is None + assert calculate_auto_price(base_price = None, auto_price_reduction = config, target_reduction_cycle = 3) is None @pytest.mark.unit def test_negative_price_reduction_count_is_treated_like_zero() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 25, min_price = 50) - assert calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - target_reduction_cycle = -3 - ) == 100 + assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = -3) == 100 @pytest.mark.unit def test_missing_price_reduction_returns_base_price() -> None: - assert calculate_auto_price( - base_price = 150, - auto_price_reduction = None, - target_reduction_cycle = 4 - ) == 150 + assert calculate_auto_price(base_price = 150, auto_price_reduction = None, target_reduction_cycle = 4) == 150 @pytest.mark.unit def test_percentage_reduction_on_float_rounds_half_up() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 12.5, min_price = 50) - assert calculate_auto_price( - base_price = 99.99, - auto_price_reduction = config, - target_reduction_cycle = 1 - ) == 87 + assert calculate_auto_price(base_price = 99.99, auto_price_reduction = config, target_reduction_cycle = 1) == 87 @pytest.mark.unit def test_fixed_reduction_on_float_rounds_half_up() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 12.4, min_price = 50) - assert calculate_auto_price( - base_price = 80.51, - auto_price_reduction = config, - target_reduction_cycle = 1 - ) == 68 + assert calculate_auto_price(base_price = 80.51, auto_price_reduction = config, target_reduction_cycle = 1) == 68 @pytest.mark.unit def test_percentage_price_reduction_over_time() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) - assert calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - 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 + assert calculate_auto_price(base_price = 100, auto_price_reduction = config, 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 def test_fixed_price_reduction_over_time() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 15, min_price = 50) - assert calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - 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 + assert calculate_auto_price(base_price = 100, auto_price_reduction = config, 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 def test_min_price_boundary_is_respected() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 20, min_price = 50) - assert calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - target_reduction_cycle = 5 - ) == 50 + assert calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 5) == 50 @pytest.mark.unit def test_min_price_zero_is_allowed() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 5, min_price = 0) - assert calculate_auto_price( - base_price = 20, - auto_price_reduction = config, - target_reduction_cycle = 5 - ) == 0 + assert calculate_auto_price(base_price = 20, auto_price_reduction = config, target_reduction_cycle = 5) == 0 @pytest.mark.unit @@ -162,26 +106,52 @@ def test_percentage_above_100_raises_error() -> None: @pytest.mark.unit def test_feature_disabled_path_leaves_price_unchanged() -> None: config = AutoPriceReductionConfig(enabled = False, strategy = "PERCENTAGE", amount = 25, min_price = 50) - price = calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - target_reduction_cycle = 4 - ) + price = calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 4) assert price == 100 @pytest.mark.unit -def test_apply_auto_price_reduction_logs_drop( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction +def test_apply_auto_price_reduction_disabled_emits_no_decision_logs( + caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> 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( price = 200, 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, - repost_count = 1 + repost_count = 1, + updated_on = None, + created_on = None, ) ad_orig:dict[str, Any] = {} @@ -199,18 +169,17 @@ def test_apply_auto_price_reduction_logs_drop( @pytest.mark.unit def test_apply_auto_price_reduction_logs_unchanged_price_at_floor( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction + caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> None: # Test scenario: price has been reduced to just above min_price, # and the next reduction would drop it below, so it gets clamped ad_cfg = SimpleNamespace( price = 95, - auto_price_reduction = AutoPriceReductionConfig( - enabled = True, strategy = "FIXED", amount = 10, min_price = 90, delay_reposts = 0, delay_days = 0 - ), + auto_price_reduction = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 10, min_price = 90, delay_reposts = 0, delay_days = 0), price_reduction_count = 0, - repost_count = 1 + repost_count = 1, + updated_on = None, + created_on = None, ) ad_orig:dict[str, Any] = {} @@ -228,17 +197,21 @@ def test_apply_auto_price_reduction_logs_unchanged_price_at_floor( @pytest.mark.unit -def test_apply_auto_price_reduction_warns_when_price_missing( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction -) -> None: +def test_apply_auto_price_reduction_warns_when_price_missing(caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None: ad_cfg = SimpleNamespace( price = None, 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, - repost_count = 2 + repost_count = 2, + updated_on = None, + created_on = None, ) ad_orig:dict[str, Any] = {} @@ -253,16 +226,22 @@ def test_apply_auto_price_reduction_warns_when_price_missing( @pytest.mark.unit def test_apply_auto_price_reduction_warns_when_min_price_equals_price( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction + caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> None: ad_cfg = SimpleNamespace( price = 100, 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, - repost_count = 1 + repost_count = 1, + updated_on = None, + created_on = None, ) ad_orig:dict[str, Any] = {} @@ -277,17 +256,21 @@ def test_apply_auto_price_reduction_warns_when_min_price_equals_price( @pytest.mark.unit -def test_apply_auto_price_reduction_respects_repost_delay( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction -) -> None: +def test_apply_auto_price_reduction_respects_repost_delay(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 = 3, delay_days = 0 + enabled = True, + strategy = "PERCENTAGE", + amount = 25, + min_price = 50, + delay_reposts = 3, + delay_days = 0, ), price_reduction_count = 0, - repost_count = 2 + repost_count = 2, + updated_on = None, + created_on = None, ) ad_orig:dict[str, Any] = {} @@ -298,19 +281,29 @@ def test_apply_auto_price_reduction_respects_repost_delay( 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) 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 -def test_apply_auto_price_reduction_after_repost_delay_reduces_once( - apply_auto_price_reduction:_ApplyAutoPriceReduction -) -> None: +def test_apply_auto_price_reduction_after_repost_delay_reduces_once(apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None: ad_cfg = SimpleNamespace( price = 100, 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, - repost_count = 3 + repost_count = 3, + updated_on = None, + created_on = None, ) ad_cfg_orig:dict[str, Any] = {} @@ -324,25 +317,36 @@ def test_apply_auto_price_reduction_after_repost_delay_reduces_once( @pytest.mark.unit def test_apply_auto_price_reduction_waits_when_reduction_already_applied( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction + caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> None: ad_cfg = SimpleNamespace( price = 100, 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, - repost_count = 3 + repost_count = 3, + updated_on = None, + created_on = None, ) 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") 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) + 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_reduction_count == 3 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 def test_apply_auto_price_reduction_respects_day_delay( - monkeypatch:pytest.MonkeyPatch, - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction + monkeypatch:pytest.MonkeyPatch, caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> None: reference = datetime(2025, 1, 1, tzinfo = timezone.utc) ad_cfg = SimpleNamespace( price = 150, 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, repost_count = 1, updated_on = reference, - created_on = reference + created_on = reference, ) 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 -def test_apply_auto_price_reduction_runs_after_delays( - monkeypatch:pytest.MonkeyPatch, - apply_auto_price_reduction:_ApplyAutoPriceReduction -) -> None: +def test_apply_auto_price_reduction_runs_after_delays(monkeypatch:pytest.MonkeyPatch, apply_auto_price_reduction:_ApplyAutoPriceReduction) -> None: reference = datetime(2025, 1, 1, tzinfo = timezone.utc) ad_cfg = SimpleNamespace( price = 120, 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, repost_count = 3, 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) @@ -405,18 +414,15 @@ def test_apply_auto_price_reduction_runs_after_delays( @pytest.mark.unit def test_apply_auto_price_reduction_delayed_when_timestamp_missing( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction + caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> None: ad_cfg = SimpleNamespace( price = 200, - auto_price_reduction = AutoPriceReductionConfig( - enabled = True, strategy = "FIXED", amount = 20, min_price = 50, delay_reposts = 0, delay_days = 2 - ), + auto_price_reduction = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 20, min_price = 50, delay_reposts = 0, delay_days = 2), price_reduction_count = 0, repost_count = 1, updated_on = None, - created_on = None + created_on = None, ) ad_orig:dict[str, Any] = {} @@ -430,18 +436,17 @@ def test_apply_auto_price_reduction_delayed_when_timestamp_missing( @pytest.mark.unit def test_fractional_reduction_increments_counter_even_when_price_unchanged( - caplog:pytest.LogCaptureFixture, - apply_auto_price_reduction:_ApplyAutoPriceReduction + caplog:pytest.LogCaptureFixture, apply_auto_price_reduction:_ApplyAutoPriceReduction ) -> None: # Test that small fractional reductions increment the counter even when rounded price doesn't change # This allows cumulative reductions to eventually show visible effect ad_cfg = SimpleNamespace( price = 100, - auto_price_reduction = AutoPriceReductionConfig( - enabled = True, strategy = "FIXED", amount = 0.3, min_price = 50, delay_reposts = 0, delay_days = 0 - ), + auto_price_reduction = AutoPriceReductionConfig(enabled = True, strategy = "FIXED", amount = 0.3, min_price = 50, delay_reposts = 0, delay_days = 0), price_reduction_count = 0, - repost_count = 1 + repost_count = 1, + updated_on = None, + created_on = None, ) 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 +@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 def test_reduction_value_zero_raises_error() -> None: 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 def test_percentage_reduction_100_percent() -> None: config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 100, min_price = 0) - assert calculate_auto_price( - base_price = 150, - auto_price_reduction = config, - target_reduction_cycle = 1 - ) == 0 + assert calculate_auto_price(base_price = 150, auto_price_reduction = config, target_reduction_cycle = 1) == 0 @pytest.mark.unit def test_extreme_reduction_cycles() -> None: # Test that extreme cycle counts don't cause performance issues or errors config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 0) - result = calculate_auto_price( - base_price = 1000, - auto_price_reduction = config, - target_reduction_cycle = 100 - ) + result = calculate_auto_price(base_price = 1000, auto_price_reduction = config, target_reduction_cycle = 100) # 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 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 2: 122 * 0.9 = 109.8 → rounds to 110 EUR # (Without intermediate rounding, it would be: 135 * 0.9^2 = 109.35 → 109 EUR) - result = calculate_auto_price( - base_price = 135, - auto_price_reduction = config, - target_reduction_cycle = 2 - ) + result = calculate_auto_price(base_price = 135, auto_price_reduction = config, target_reduction_cycle = 2) 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: # Test that extreme cycles stop at min_price and don't cause issues config = AutoPriceReductionConfig(enabled = True, strategy = "PERCENTAGE", amount = 10, min_price = 50) - result = calculate_auto_price( - base_price = 1000, - auto_price_reduction = config, - target_reduction_cycle = 1000 - ) + result = calculate_auto_price(base_price = 1000, auto_price_reduction = config, target_reduction_cycle = 1000) # Should stop at min_price, not go to 0, regardless of cycle count 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 # But min_price=90.5 rounds UP to 91 with ROUND_CEILING # So the result should be 91, not 90 - result = calculate_auto_price( - base_price = 100, - auto_price_reduction = config, - target_reduction_cycle = 1 - ) + result = calculate_auto_price(base_price = 100, auto_price_reduction = config, target_reduction_cycle = 1) assert result == 91 # Rounded up from 90.5 floor # Verify with another fractional value @@ -545,6 +555,6 @@ def test_fractional_min_price_is_rounded_up_with_ceiling() -> None: result2 = calculate_auto_price( base_price = 60, 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