mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 10:31:50 +01:00
feat: explain auto price reduction decisions and traces (#826)
This commit is contained in:
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
#################################################
|
||||
|
||||
Reference in New Issue
Block a user