shopping-cli 3.2.4__tar.gz → 3.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. {shopping_cli-3.2.4/shopping_cli.egg-info → shopping_cli-3.2.5}/PKG-INFO +1 -1
  2. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/pyproject.toml +1 -1
  3. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/__init__.py +1 -1
  4. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/merchant_agent.py +23 -0
  5. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/catalog.py +6 -0
  6. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/listings_projection.py +8 -1
  7. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli.py +22 -0
  8. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_catalog_commands.py +39 -0
  9. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/catalog.py +222 -1
  10. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/catalog_views.py +4 -0
  11. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/db/migrations.py +26 -1
  12. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/db/models.py +9 -0
  13. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/listings/projection.py +13 -0
  14. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation.py +6 -3
  15. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_gates.py +5 -3
  16. {shopping_cli-3.2.4 → shopping_cli-3.2.5/shopping_cli.egg-info}/PKG-INFO +1 -1
  17. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/LICENSE +0 -0
  18. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/MANIFEST.in +0 -0
  19. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/README.md +0 -0
  20. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/setup.cfg +0 -0
  21. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/__init__.py +0 -0
  22. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/agent_cli.py +0 -0
  23. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/buyer_cli.py +0 -0
  24. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/merchant_daemon.py +0 -0
  25. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/merchant_state_helpers.py +0 -0
  26. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/agents/tools.py +0 -0
  27. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/__init__.py +0 -0
  28. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/app.py +0 -0
  29. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/auth.py +0 -0
  30. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/error_handlers.py +0 -0
  31. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/error_response.py +0 -0
  32. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/fallback_asgi.py +0 -0
  33. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/fastapi_routes.py +0 -0
  34. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/__init__.py +0 -0
  35. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/agents.py +0 -0
  36. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/audit.py +0 -0
  37. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/buyer.py +0 -0
  38. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/common.py +0 -0
  39. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/conversations.py +0 -0
  40. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/erp.py +0 -0
  41. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/human_review.py +0 -0
  42. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/handlers/negotiation.py +0 -0
  43. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/idempotency.py +0 -0
  44. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/limits.py +0 -0
  45. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/request_dispatch.py +0 -0
  46. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/request_limits.py +0 -0
  47. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/route_matching.py +0 -0
  48. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/route_registry.py +0 -0
  49. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/route_table.py +0 -0
  50. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/routes_agents.py +0 -0
  51. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/routes_conversations.py +0 -0
  52. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/routes_marketplace.py +0 -0
  53. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/routes_merchants.py +0 -0
  54. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/api/server.py +0 -0
  55. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_adapter_commands.py +0 -0
  56. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_agent_commands.py +0 -0
  57. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_buyer_commands.py +0 -0
  58. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_common.py +0 -0
  59. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_conversation_commands.py +0 -0
  60. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_erp_commands.py +0 -0
  61. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/cli_listing_commands.py +0 -0
  62. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/commerce/__init__.py +0 -0
  63. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/commerce/adapters.py +0 -0
  64. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/commerce/data_source.py +0 -0
  65. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/config.py +0 -0
  66. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/contracts/kiwi-contracts.lock.json +0 -0
  67. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
  68. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
  69. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
  70. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
  71. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/__init__.py +0 -0
  72. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/catalog_scoring.py +0 -0
  73. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/catalog_text.py +0 -0
  74. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/channels.py +0 -0
  75. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/conversations.py +0 -0
  76. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/delivery.py +0 -0
  77. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/errors.py +0 -0
  78. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/harness.py +0 -0
  79. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/limits.py +0 -0
  80. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/negotiation.py +0 -0
  81. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/negotiation_contracts.py +0 -0
  82. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/policies.py +0 -0
  83. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/risk.py +0 -0
  84. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/core/tokens.py +0 -0
  85. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/data_sources/__init__.py +0 -0
  86. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/data_sources/adapter.py +0 -0
  87. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/data_sources/csv_excel_source.py +0 -0
  88. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/data_sources/erp_source.py +0 -0
  89. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/db/__init__.py +0 -0
  90. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/db/provenance.py +0 -0
  91. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/db/session.py +0 -0
  92. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/http_client.py +0 -0
  93. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/listings/__init__.py +0 -0
  94. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/__init__.py +0 -0
  95. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/agents.py +0 -0
  96. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/audit.py +0 -0
  97. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/buyer_bootstrap.py +0 -0
  98. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/conversations.py +0 -0
  99. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/human_review.py +0 -0
  100. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_message.py +0 -0
  101. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
  102. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_policy_result.py +0 -0
  103. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_snapshot.py +0 -0
  104. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
  105. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli/services/tokens.py +0 -0
  106. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli.egg-info/SOURCES.txt +0 -0
  107. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli.egg-info/dependency_links.txt +0 -0
  108. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli.egg-info/entry_points.txt +0 -0
  109. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli.egg-info/requires.txt +0 -0
  110. {shopping_cli-3.2.4 → shopping_cli-3.2.5}/shopping_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.2.4
3
+ Version: 3.2.5
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "shopping-cli"
7
- version = "3.2.4"
7
+ version = "3.2.5"
8
8
  description = "Standalone local-commerce AI consultation marketplace runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Standalone shopping-cli consultation runtime."""
2
2
 
3
- VERSION = "3.2.4"
3
+ VERSION = "3.2.5"
@@ -115,6 +115,29 @@ def _authorized_bargain_amount(product: dict[str, Any]) -> str:
115
115
  return ""
116
116
 
117
117
 
118
+ def _effective_floor_price(product: dict[str, Any]) -> float | None:
119
+ """解析商家授权的最低成交价(v26 结构化优先)。
120
+
121
+ 优先级:``product.floor_price > 0`` → 用它;否则
122
+ ``0 < max_discount_percent <= 100`` → ``price * (1 - discount/100)``;
123
+ 否则回退 ``_authorized_bargain_amount`` 自由文本正则(存量
124
+ ``automation_boundaries`` 兼容)。全无 → None(无授权底价)。
125
+ """
126
+ floor = _safe_non_negative_float(product.get("floor_price"))
127
+ if floor > 0:
128
+ return floor
129
+ discount = _safe_non_negative_float(product.get("max_discount_percent"))
130
+ if 0 < discount <= 100:
131
+ return _safe_non_negative_float(product.get("price")) * (1 - discount / 100)
132
+ free_text = _authorized_bargain_amount(product)
133
+ if free_text:
134
+ try:
135
+ return float(free_text)
136
+ except (TypeError, ValueError):
137
+ return None
138
+ return None
139
+
140
+
118
141
  def generate_reply(
119
142
  tools: MerchantAgentTools,
120
143
  conversation: dict[str, Any],
@@ -320,6 +320,9 @@ def create_product(
320
320
  description=str(payload.get("description") or ""),
321
321
  delivery_attributes=payload.get("delivery_attributes") or [],
322
322
  handoff_destination=str(payload.get("handoff_destination") or ""),
323
+ floor_price=payload.get("floor_price", 0.0),
324
+ max_discount_percent=payload.get("max_discount_percent", 0.0),
325
+ promotions=payload.get("promotions"),
323
326
  )
324
327
  return {"ok": True, "product": product}
325
328
 
@@ -349,6 +352,9 @@ def update_product(
349
352
  handoff_destination=payload.get("handoff_destination")
350
353
  if "handoff_destination" in payload
351
354
  else None,
355
+ floor_price=payload.get("floor_price"),
356
+ max_discount_percent=payload.get("max_discount_percent"),
357
+ promotions=payload.get("promotions") if "promotions" in payload else None,
352
358
  )
353
359
  return {"ok": True, "product": product}
354
360
 
@@ -62,7 +62,14 @@ def get_listing_projection(db_path: str | Path, sku: str, payload: dict[str, Any
62
62
  )
63
63
  try:
64
64
  # 审查 S-M3:私有剥离收进投影层(include_private)。
65
- projection = project_product_listing(conn, sku, include_private=is_owner)
65
+ # merchant_id 由商品 owner 派生(配送时效是商家级字段,投影需要它);
66
+ # 此前缺省 "" 会让商家级 delivery_times 在该路径静默丢失。
67
+ projection = project_product_listing(
68
+ conn,
69
+ sku,
70
+ merchant_id=str(row["merchant_id"] or "") if row is not None else "",
71
+ include_private=is_owner,
72
+ )
66
73
  except ProjectionError as exc:
67
74
  raise NotFoundError(str(exc)) from exc
68
75
  return {"ok": True, "projection": projection}
@@ -33,7 +33,10 @@ from shopping_cli.cli_agent_commands import (
33
33
  cmd_agent_tokens,
34
34
  )
35
35
  from shopping_cli.cli_catalog_commands import (
36
+ cmd_delivery_remove_time,
36
37
  cmd_delivery_set,
38
+ cmd_delivery_set_time,
39
+ cmd_delivery_times,
37
40
  cmd_merchant_create,
38
41
  cmd_merchant_list,
39
42
  cmd_merchant_update,
@@ -511,6 +514,25 @@ def build_parser() -> argparse.ArgumentParser:
511
514
  delivery_set.add_argument("--format", choices=["text", "json"], default="text")
512
515
  delivery_set.set_defaults(func=cmd_delivery_set)
513
516
 
517
+ delivery_set_time = delivery_sub.add_parser("set-time", help="Set delivery time for one region (days)")
518
+ delivery_set_time.add_argument("--merchant", required=True)
519
+ delivery_set_time.add_argument("--region", required=True, help="区域名,如 东北 / 华北")
520
+ delivery_set_time.add_argument("--min-days", type=positive_int, required=True)
521
+ delivery_set_time.add_argument("--max-days", type=positive_int, required=True)
522
+ delivery_set_time.add_argument("--format", choices=["text", "json"], default="text")
523
+ delivery_set_time.set_defaults(func=cmd_delivery_set_time)
524
+
525
+ delivery_remove_time = delivery_sub.add_parser("remove-time", help="Remove delivery time for one region")
526
+ delivery_remove_time.add_argument("--merchant", required=True)
527
+ delivery_remove_time.add_argument("--region", required=True)
528
+ delivery_remove_time.add_argument("--format", choices=["text", "json"], default="text")
529
+ delivery_remove_time.set_defaults(func=cmd_delivery_remove_time)
530
+
531
+ delivery_times = delivery_sub.add_parser("times", help="Show per-region delivery times")
532
+ delivery_times.add_argument("--merchant", required=True)
533
+ delivery_times.add_argument("--format", choices=["text", "json"], default="text")
534
+ delivery_times.set_defaults(func=cmd_delivery_times)
535
+
514
536
  product = subparsers.add_parser("product", help="Manage products and stock")
515
537
  product_sub = product.add_subparsers(dest="product_command", required=True)
516
538
  product_add = product_sub.add_parser("add", help="Publish a product")
@@ -9,9 +9,13 @@ from shopping_cli.cli_common import db_path_from_args, emit
9
9
  from shopping_cli.core.catalog import (
10
10
  create_merchant,
11
11
  create_product,
12
+ delivery_rule,
12
13
  list_merchants,
14
+ remove_delivery_time,
15
+ render_delivery_times_hint,
13
16
  search_merchants,
14
17
  search_products,
18
+ set_delivery_time,
15
19
  set_stock,
16
20
  update_merchant,
17
21
  update_product,
@@ -112,6 +116,41 @@ def emit_delivery_rule_text(merchant_id: str, delivery: dict[str, Any]) -> None:
112
116
  print(f"ETA: {int(delivery.get('eta_minutes') or 0)} minutes")
113
117
  print(f"Radius: {float(delivery.get('radius_km') or 0):g} km")
114
118
  print(f"Notes: {delivery.get('notes') or '-'}")
119
+ print(f"Delivery times: {render_delivery_times_hint(delivery.get('delivery_times'))}")
120
+
121
+
122
+ def cmd_delivery_set_time(args: argparse.Namespace) -> None:
123
+ with db_session(db_path_from_args(args)) as conn:
124
+ delivery = set_delivery_time(
125
+ conn,
126
+ args.merchant,
127
+ region=args.region,
128
+ min_days=args.min_days,
129
+ max_days=args.max_days,
130
+ )
131
+ if args.format == "text":
132
+ print(f"Delivery time set: {args.merchant} {render_delivery_times_hint(delivery.get('delivery_times'))}")
133
+ return
134
+ emit({"ok": True, "merchant_id": args.merchant, "delivery": delivery}, args.format)
135
+
136
+
137
+ def cmd_delivery_remove_time(args: argparse.Namespace) -> None:
138
+ with db_session(db_path_from_args(args)) as conn:
139
+ delivery = remove_delivery_time(conn, args.merchant, region=args.region)
140
+ if args.format == "text":
141
+ print(f"Delivery time removed: {args.merchant} {render_delivery_times_hint(delivery.get('delivery_times'))}")
142
+ return
143
+ emit({"ok": True, "merchant_id": args.merchant, "delivery": delivery}, args.format)
144
+
145
+
146
+ def cmd_delivery_times(args: argparse.Namespace) -> None:
147
+ with db_session(db_path_from_args(args)) as conn:
148
+ delivery = delivery_rule(conn, args.merchant)
149
+ if args.format == "text":
150
+ print(f"Delivery times: {args.merchant}")
151
+ print(render_delivery_times_hint(delivery.get("delivery_times")))
152
+ return
153
+ emit({"ok": True, "merchant_id": args.merchant, "delivery_times": delivery.get("delivery_times", {})}, args.format)
115
154
 
116
155
 
117
156
  def cmd_product_add(args: argparse.Namespace) -> None:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import math
6
7
  import re
7
8
  import sqlite3
@@ -45,6 +46,51 @@ def parse_tags(value: str | list[str] | None) -> list[str]:
45
46
  return bounded_string_list([part.strip() for part in parts if part.strip()], "tags")
46
47
 
47
48
 
49
+ def parse_promotions(value: list | None) -> list[dict[str, Any]]:
50
+ """促销数组轻校验(v26):list of dict,每项含非空 title。返回可 JSON 编码 list。
51
+
52
+ 促销为商家私有元数据,本里程碑只做形态校验 + 存取,不参与谈判门判断。
53
+ """
54
+ if value is None:
55
+ return []
56
+ if not isinstance(value, list):
57
+ raise ValidationError("promotions must be a list")
58
+ result: list[dict[str, Any]] = []
59
+ for item in value:
60
+ if not isinstance(item, dict):
61
+ raise ValidationError("each promotion must be an object")
62
+ title = str(item.get("title") or "").strip()
63
+ if not title:
64
+ raise ValidationError("each promotion must have a non-empty title")
65
+ result.append(item)
66
+ return result
67
+
68
+
69
+ def decode_promotions(value: str | None) -> list[dict[str, Any]]:
70
+ """解码 promotions_json:list of dict(区别于 ``decode_json`` 的字符串列表归一化)。
71
+
72
+ ``decode_json(value, [])`` 会把 list 归一化为 list[str] 并丢弃 dict 项,
73
+ 不适合促销数组(元素为 dict);本函数按 list-of-dict 解码,非法/空值回退 []。
74
+ """
75
+ if not value:
76
+ return []
77
+ try:
78
+ decoded = json.loads(value)
79
+ except (TypeError, ValueError):
80
+ return []
81
+ if not isinstance(decoded, list):
82
+ return []
83
+ return [item for item in decoded if isinstance(item, dict)]
84
+
85
+
86
+ def _discount_percent(value: Any, message: str) -> float:
87
+ """最大折扣率校验:finite + 0 <= x <= 100(0=无折扣授权)。"""
88
+ percent = _finite_float(value, message)
89
+ if percent < 0 or percent > 100:
90
+ raise ValidationError(f"{message} (must be between 0 and 100)")
91
+ return percent
92
+
93
+
48
94
  def require_merchant(conn: sqlite3.Connection, merchant_id: str) -> sqlite3.Row:
49
95
  row = conn.execute("select * from merchants where id = ?", (merchant_id,)).fetchone()
50
96
  if row is None:
@@ -330,6 +376,145 @@ def upsert_delivery_rule(
330
376
  return delivery_rule(conn, merchant_id)
331
377
 
332
378
 
379
+ # 配送时效天数上限(区域时效为天数区间,封顶防止脏数据进入发现投影)。
380
+ MAX_DELIVERY_DAYS = 365
381
+
382
+
383
+ def decode_delivery_times(value: str | None) -> dict[str, dict[str, int]]:
384
+ """解码 delivery_times_json:区域 → {min_days, max_days}。
385
+
386
+ 仿 ``decode_promotions``(list-of-dict)按 dict-of-dict 校验:非 dict /
387
+ 损坏 / 条目缺 min_days/max_days 或非正整数 → 丢弃该条,整体非法回退 {}。
388
+ 不抛异常(数据面读取容错),写入侧由 ``set_delivery_time`` 强校验。
389
+ """
390
+ if not value:
391
+ return {}
392
+ try:
393
+ decoded = json.loads(value)
394
+ except (TypeError, ValueError):
395
+ return {}
396
+ if not isinstance(decoded, dict):
397
+ return {}
398
+ result: dict[str, dict[str, int]] = {}
399
+ for region, entry in decoded.items():
400
+ if not isinstance(region, str) or not region.strip():
401
+ continue
402
+ if not isinstance(entry, dict):
403
+ continue
404
+ try:
405
+ min_days = int(entry.get("min_days"))
406
+ max_days = int(entry.get("max_days"))
407
+ except (TypeError, ValueError):
408
+ continue
409
+ if min_days < 1 or max_days < min_days or max_days > MAX_DELIVERY_DAYS:
410
+ continue
411
+ result[region.strip()] = {"min_days": min_days, "max_days": max_days}
412
+ return result
413
+
414
+
415
+ def set_delivery_time(
416
+ conn: sqlite3.Connection,
417
+ merchant_id: str,
418
+ region: str,
419
+ min_days: int,
420
+ max_days: int,
421
+ ) -> dict[str, Any]:
422
+ """维护商家某一区域的配送时效(upsert 单个区域,不影响其它区域)。
423
+
424
+ region → {min_days, max_days} 写回 delivery_rules.delivery_times_json。
425
+ 行不存在则插入(其余配送列保持默认),存在则只更新该 JSON + updated_at
426
+ —— 不触碰 service_area/fee/eta_minutes/radius_km/notes。
427
+ """
428
+ region = bounded_text(region, "delivery region", MAX_SHORT_TEXT_CHARS).strip()
429
+ if not region:
430
+ raise ValidationError("delivery region must not be empty")
431
+ min_days = _whole_int(min_days, "delivery min days must be a whole number")
432
+ max_days = _whole_int(max_days, "delivery max days must be a whole number")
433
+ if min_days < 1:
434
+ raise ValidationError("delivery min days must be at least 1")
435
+ if max_days < min_days:
436
+ raise ValidationError("delivery max days must be >= min days")
437
+ if max_days > MAX_DELIVERY_DAYS:
438
+ raise ValidationError(f"delivery max days must be <= {MAX_DELIVERY_DAYS}")
439
+ require_merchant(conn, merchant_id)
440
+ times = delivery_times(conn, merchant_id)
441
+ times[region] = {"min_days": min_days, "max_days": max_days}
442
+ now = now_iso()
443
+ conn.execute(
444
+ """
445
+ insert into delivery_rules(merchant_id, delivery_times_json, created_at, updated_at)
446
+ values (?, ?, ?, ?)
447
+ on conflict(merchant_id) do update set
448
+ delivery_times_json = excluded.delivery_times_json,
449
+ updated_at = excluded.updated_at
450
+ """,
451
+ (merchant_id, encode_json(times), now, now),
452
+ )
453
+ _audit_catalog(
454
+ conn,
455
+ merchant_id,
456
+ "delivery_time_updated",
457
+ {"merchant_id": merchant_id, "region": region, "min_days": min_days, "max_days": max_days},
458
+ )
459
+ return delivery_rule(conn, merchant_id)
460
+
461
+
462
+ def remove_delivery_time(conn: sqlite3.Connection, merchant_id: str, region: str) -> dict[str, Any]:
463
+ """删除商家某一区域的配送时效(不存在则原样返回,幂等)。"""
464
+ region = bounded_text(region, "delivery region", MAX_SHORT_TEXT_CHARS).strip()
465
+ require_merchant(conn, merchant_id)
466
+ times = delivery_times(conn, merchant_id)
467
+ if region not in times:
468
+ return delivery_rule(conn, merchant_id)
469
+ del times[region]
470
+ now = now_iso()
471
+ conn.execute(
472
+ "update delivery_rules set delivery_times_json = ?, updated_at = ? where merchant_id = ?",
473
+ (encode_json(times), now, merchant_id),
474
+ )
475
+ _audit_catalog(
476
+ conn,
477
+ merchant_id,
478
+ "delivery_time_removed",
479
+ {"merchant_id": merchant_id, "region": region},
480
+ )
481
+ return delivery_rule(conn, merchant_id)
482
+
483
+
484
+ def delivery_times(conn: sqlite3.Connection, merchant_id: str) -> dict[str, dict[str, int]]:
485
+ """读商家配送时效映射(只读、容错;空 → {})。"""
486
+ row = conn.execute(
487
+ "select delivery_times_json from delivery_rules where merchant_id = ?", (merchant_id,)
488
+ ).fetchone()
489
+ return decode_delivery_times(row["delivery_times_json"] if row is not None else None)
490
+
491
+
492
+ def render_delivery_times_hint(delivery_times_map: Mapping[str, Any] | None) -> str:
493
+ """区域时效摘要(发现投影 lead_time_hint / CLI 展示),单一来源。
494
+
495
+ 例:`{"东北": {"min_days": 3, "max_days": 4}, "华北": {"min_days": 1, "max_days": 2}}`
496
+ → `东北 3-4天;华北 1-2天`;min==max 渲染 `东北 3天`;空 → `-`。
497
+ """
498
+ if not delivery_times_map:
499
+ return "-"
500
+ parts: list[str] = []
501
+ for region, entry in delivery_times_map.items():
502
+ if not isinstance(entry, dict):
503
+ continue
504
+ try:
505
+ min_days = int(entry.get("min_days") or 0)
506
+ max_days = int(entry.get("max_days") or 0)
507
+ except (TypeError, ValueError):
508
+ continue
509
+ if min_days < 1:
510
+ continue
511
+ if min_days == max_days:
512
+ parts.append(f"{region} {min_days}天")
513
+ else:
514
+ parts.append(f"{region} {min_days}-{max_days}天")
515
+ return ";".join(parts) if parts else "-"
516
+
517
+
333
518
  def create_product(
334
519
  conn: sqlite3.Connection,
335
520
  merchant_id: str,
@@ -343,6 +528,9 @@ def create_product(
343
528
  description: str = "",
344
529
  delivery_attributes: str | list[str] | None = None,
345
530
  handoff_destination: str = "",
531
+ floor_price: float = 0.0,
532
+ max_discount_percent: float = 0.0,
533
+ promotions: list | None = None,
346
534
  ) -> dict[str, Any]:
347
535
  merchant_id = bounded_text(merchant_id, "merchant id", MAX_SHORT_TEXT_CHARS).strip()
348
536
  sku = bounded_text(sku, "product sku", MAX_SHORT_TEXT_CHARS).strip()
@@ -362,6 +550,9 @@ def create_product(
362
550
  stock = _whole_int(stock, "--stock must be a whole number")
363
551
  if stock < 0:
364
552
  raise ValidationError("--stock must be non-negative")
553
+ floor_price = _price_with_precision(floor_price, "--floor-price must be finite and non-negative")
554
+ max_discount_percent = _discount_percent(max_discount_percent, "--max-discount-percent")
555
+ promotions = parse_promotions(promotions)
365
556
  require_merchant(conn, merchant_id)
366
557
  now = now_iso()
367
558
  try:
@@ -370,9 +561,10 @@ def create_product(
370
561
  insert into products(
371
562
  sku, merchant_id, title, description, category, tags_json, price,
372
563
  currency, stock, delivery_attributes_json, handoff_destination,
564
+ floor_price, max_discount_percent, promotions_json,
373
565
  active, created_at, updated_at
374
566
  )
375
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
567
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
376
568
  """,
377
569
  (
378
570
  sku,
@@ -386,6 +578,9 @@ def create_product(
386
578
  stock,
387
579
  encode_json(parse_tags(delivery_attributes)),
388
580
  handoff_destination,
581
+ floor_price,
582
+ max_discount_percent,
583
+ encode_json(promotions),
389
584
  now,
390
585
  now,
391
586
  ),
@@ -410,6 +605,9 @@ def update_product(
410
605
  description: str | None = None,
411
606
  delivery_attributes: str | list[str] | None = None,
412
607
  handoff_destination: str | None = None,
608
+ floor_price: float | None = None,
609
+ max_discount_percent: float | None = None,
610
+ promotions: list | None = None,
413
611
  ) -> dict[str, Any]:
414
612
  product = require_product(conn, sku)
415
613
  if merchant_id and product["merchant_id"] != merchant_id:
@@ -435,6 +633,12 @@ def update_product(
435
633
  stock = _whole_int(stock, "--stock must be a whole number")
436
634
  if stock is not None and stock < 0:
437
635
  raise ValidationError("--stock must be non-negative")
636
+ if floor_price is not None:
637
+ floor_price = _price_with_precision(floor_price, "--floor-price must be finite and non-negative")
638
+ if max_discount_percent is not None:
639
+ max_discount_percent = _discount_percent(max_discount_percent, "--max-discount-percent")
640
+ if promotions is not None:
641
+ promotions = parse_promotions(promotions)
438
642
  updates: list[str] = []
439
643
  values: list[Any] = []
440
644
  field_map = {
@@ -444,6 +648,8 @@ def update_product(
444
648
  "currency": currency,
445
649
  "category": category,
446
650
  "description": description,
651
+ "floor_price": floor_price,
652
+ "max_discount_percent": max_discount_percent,
447
653
  }
448
654
  for column, value in field_map.items():
449
655
  if value is not None:
@@ -455,6 +661,9 @@ def update_product(
455
661
  if delivery_attributes is not None:
456
662
  updates.append("delivery_attributes_json = ?")
457
663
  values.append(encode_json(parse_tags(delivery_attributes)))
664
+ if promotions is not None:
665
+ updates.append("promotions_json = ?")
666
+ values.append(encode_json(promotions))
458
667
  if handoff_destination is not None:
459
668
  updates.append("handoff_destination = ?")
460
669
  values.append(handoff_destination)
@@ -499,6 +708,7 @@ def delivery_rule(conn: sqlite3.Connection, merchant_id: str) -> dict[str, Any]:
499
708
  "eta_minutes": 0,
500
709
  "radius_km": 0.0,
501
710
  "notes": "",
711
+ "delivery_times": {},
502
712
  }
503
713
  return {
504
714
  "service_area": row["service_area"],
@@ -507,6 +717,7 @@ def delivery_rule(conn: sqlite3.Connection, merchant_id: str) -> dict[str, Any]:
507
717
  "eta_minutes": _safe_non_negative_int(row["eta_minutes"]),
508
718
  "radius_km": _safe_non_negative_float(row["radius_km"]),
509
719
  "notes": row["notes"],
720
+ "delivery_times": decode_delivery_times(row["delivery_times_json"]),
510
721
  }
511
722
 
512
723
 
@@ -538,6 +749,7 @@ def _delivery_rule_from_joined_merchant(row: sqlite3.Row) -> dict[str, Any]:
538
749
  "eta_minutes": _safe_non_negative_int(row["delivery_eta_minutes"]),
539
750
  "radius_km": _safe_non_negative_float(row["delivery_radius_km"]),
540
751
  "notes": row["delivery_notes"] or "",
752
+ "delivery_times": decode_delivery_times(row["delivery_times_json"]),
541
753
  }
542
754
 
543
755
 
@@ -568,6 +780,7 @@ def list_merchants(conn: sqlite3.Connection, limit: int = 50, offset: int = 0) -
568
780
  dr.eta_minutes as delivery_eta_minutes,
569
781
  dr.radius_km as delivery_radius_km,
570
782
  dr.notes as delivery_notes,
783
+ dr.delivery_times_json as delivery_times_json,
571
784
  count(p.sku) as active_product_count
572
785
  from merchants m
573
786
  left join delivery_rules dr on dr.merchant_id = m.id
@@ -596,6 +809,9 @@ def product_summary(conn: sqlite3.Connection, sku: str) -> dict[str, Any]:
596
809
  "stock": _safe_non_negative_int(product["stock"]),
597
810
  "delivery_attributes": decode_json(product["delivery_attributes_json"], []),
598
811
  "handoff_destination": product["handoff_destination"] or "",
812
+ "floor_price": _safe_non_negative_float(product["floor_price"]),
813
+ "max_discount_percent": _safe_non_negative_float(product["max_discount_percent"]),
814
+ "promotions": decode_promotions(product["promotions_json"]),
599
815
  "merchant": merchant,
600
816
  "delivery": merchant["delivery"],
601
817
  "warnings": product_warnings(product, merchant),
@@ -631,6 +847,9 @@ def _product_summary_from_search_row(row: sqlite3.Row) -> dict[str, Any]:
631
847
  "stock": _safe_non_negative_int(row["stock"]),
632
848
  "delivery_attributes": decode_json(row["delivery_attributes_json"], []),
633
849
  "handoff_destination": row["handoff_destination"] or "",
850
+ "floor_price": _safe_non_negative_float(row["floor_price"]),
851
+ "max_discount_percent": _safe_non_negative_float(row["max_discount_percent"]),
852
+ "promotions": decode_promotions(row["promotions_json"]),
634
853
  "merchant": merchant,
635
854
  "delivery": merchant["delivery"],
636
855
  "warnings": product_warnings(row, merchant),
@@ -991,6 +1210,7 @@ def search_products(
991
1210
  dr.eta_minutes as delivery_eta_minutes,
992
1211
  dr.radius_km as delivery_radius_km,
993
1212
  dr.notes as delivery_notes,
1213
+ dr.delivery_times_json as delivery_times_json,
994
1214
  (
995
1215
  select count(*)
996
1216
  from products pc
@@ -1127,6 +1347,7 @@ def search_merchants(
1127
1347
  dr.eta_minutes as delivery_eta_minutes,
1128
1348
  dr.radius_km as delivery_radius_km,
1129
1349
  dr.notes as delivery_notes,
1350
+ dr.delivery_times_json as delivery_times_json,
1130
1351
  count(p.sku) as active_product_count
1131
1352
  """
1132
1353
  if use_index:
@@ -30,6 +30,10 @@ def public_product_summary(product: dict[str, Any]) -> dict[str, Any]:
30
30
  """
31
31
  summary = dict(product)
32
32
  summary.pop("handoff_destination", None)
33
+ # v26 — 商家私有价格边界:底价/折扣率/促销对买家一律剥离(public-only §7)。
34
+ summary.pop("floor_price", None)
35
+ summary.pop("max_discount_percent", None)
36
+ summary.pop("promotions", None)
33
37
  if "stock" in summary:
34
38
  stock = summary.pop("stock", None)
35
39
  summary["availability_hint"] = (
@@ -10,7 +10,7 @@ from typing import Callable
10
10
 
11
11
  from shopping_cli.core.tokens import is_sha256_digest, token_digest, token_prefix, token_suffix
12
12
 
13
- CURRENT_SCHEMA_VERSION = 25
13
+ CURRENT_SCHEMA_VERSION = 27
14
14
 
15
15
 
16
16
  @dataclass(frozen=True)
@@ -529,6 +529,29 @@ def migration_025_negotiation_decision_idempotency(conn: sqlite3.Connection) ->
529
529
  )
530
530
 
531
531
 
532
+ def migration_026_product_pricing_boundaries(conn: sqlite3.Connection) -> None:
533
+ """products 商家私有价格边界(结构化字段,替代 automation_boundaries 自由文本)。
534
+
535
+ 底价/折扣/促销从「商家级自由文本 + 正则解析」迁移为「每商品结构化列」:
536
+ floor_price(底价,0=未设)、max_discount_percent(最大折扣率 0-100,0=无
537
+ 折扣授权)、promotions_json(结构化促销数组)。三者均私有,买家投影剥离。
538
+ 幂等加列(ensure_column 已存在列跳过);存量行默认 0/[],行为向后兼容。
539
+ """
540
+ ensure_column(conn, "products", "floor_price", "real not null default 0")
541
+ ensure_column(conn, "products", "max_discount_percent", "real not null default 0")
542
+ ensure_column(conn, "products", "promotions_json", "text not null default '[]'")
543
+
544
+
545
+ def migration_027_delivery_times(conn: sqlite3.Connection) -> None:
546
+ """delivery_rules.delivery_times_json 列(商家按区域配送时效,发现指标)。
547
+
548
+ 商家级一个字段:区域 → {min_days, max_days}(如 东北 3-4 天、华北 1-2 天)。
549
+ 买家在发现/搜索阶段看到摘要(listings 投影 → commercial_hints.lead_time_hint)。
550
+ 幂等加列(ensure_column 已存在列跳过);存量行默认 '{}',行为向后兼容。
551
+ """
552
+ ensure_column(conn, "delivery_rules", "delivery_times_json", "text not null default '{}'")
553
+
554
+
532
555
  MIGRATIONS: tuple[Migration, ...] = (
533
556
  Migration(1, "conversation_next_actor", migration_001_conversation_next_actor),
534
557
  Migration(2, "agent_runtime_columns", migration_002_agent_runtime_columns),
@@ -548,6 +571,8 @@ MIGRATIONS: tuple[Migration, ...] = (
548
571
  Migration(23, "remove_scheme_a_stub_merchants", migration_023_remove_scheme_a_stub_merchants),
549
572
  Migration(24, "product_source_csv_excel", migration_024_product_source_csv_excel),
550
573
  Migration(25, "negotiation_decision_idempotency", migration_025_negotiation_decision_idempotency),
574
+ Migration(26, "product_pricing_boundaries", migration_026_product_pricing_boundaries),
575
+ Migration(27, "delivery_times", migration_027_delivery_times),
551
576
  )
552
577
 
553
578
 
@@ -40,6 +40,14 @@ SCHEMA = [
40
40
  -- 交易入口(URL 类为 https URL;联系/会话类为 opaque ref),publish 时
41
41
  -- 同步进 catalog listing 的 handoff_destination_ref。
42
42
  handoff_destination text not null default '',
43
+ -- v26 — 商家私有价格边界(结构化字段,替代 automation_boundaries 自由文本):
44
+ -- floor_price = 底价(最小成交价,0=未设);max_discount_percent =
45
+ -- 最大授权折扣率 0-100(0=无折扣授权);promotions_json = 结构化促销
46
+ -- 数组(商家私有)。三者均为私有,买家可见路径(public_product_summary)
47
+ -- 一律剥离;merchant owner 可见可改。
48
+ floor_price real not null default 0,
49
+ max_discount_percent real not null default 0,
50
+ promotions_json text not null default '[]',
43
51
  active integer not null default 1,
44
52
  source text not null default 'local'
45
53
  check(source in ('local','erp','csv_excel')),
@@ -80,6 +88,7 @@ SCHEMA = [
80
88
  eta_minutes integer not null default 0,
81
89
  radius_km real not null default 0,
82
90
  notes text not null default '',
91
+ delivery_times_json text not null default '{}',
83
92
  created_at text not null,
84
93
  updated_at text not null,
85
94
  foreign key (merchant_id) references merchants(id)
@@ -14,6 +14,7 @@ import sqlite3
14
14
  from datetime import datetime, timezone
15
15
  from typing import Any
16
16
 
17
+ from shopping_cli.core.catalog import delivery_times, render_delivery_times_hint
17
18
  from shopping_cli.data_sources.erp_source import SOURCE_LOCAL
18
19
 
19
20
 
@@ -68,6 +69,18 @@ def project_product_listing(
68
69
  "regions": [],
69
70
  "tags": json.loads(row.get("tags_json") or "[]"),
70
71
  }
72
+ # 商家配送时效(按区域,如 东北 3-4 天)→ 公开发现 hint:买家在搜索/发现
73
+ # 阶段看到时效摘要与履约区域。商家级字段(delivery_rules.delivery_times_json),
74
+ # 由商家用 `delivery set-time` 维护;为空保持 regions:[] 且无 commercial_hints。
75
+ if merchant_id:
76
+ times = delivery_times(conn, merchant_id)
77
+ if times:
78
+ region_names = sorted(times.keys())
79
+ projection["regions"] = region_names
80
+ projection["commercial_hints"] = {
81
+ "fulfillment_regions": region_names,
82
+ "lead_time_hint": render_delivery_times_hint(times),
83
+ }
71
84
  # 每商品成交入口(KTH destination_ref):商家自行维护,publish 时同步进
72
85
  # catalog listing 的 handoff_destination_ref。审查 S-M3:私有字段,缺省
73
86
  # 剥离;仅 owner(include_private=True)保留。
@@ -514,7 +514,10 @@ def _merchant_gate(
514
514
  # SKU 复用/重建时会对错误商家的 automation_boundaries 做 floor/折扣判断。
515
515
  # 换用 _load_conversation_product(带归属校验,fail-closed)。
516
516
  product = _load_conversation_product(conn, conversation)
517
- floor_str = merchant_agent._authorized_bargain_amount(product)
517
+ # v26:结构化底价/折扣优先(floor_price / max_discount_percent),回退
518
+ # automation_boundaries 自由文本正则(_effective_floor_price 内部处理)。
519
+ effective_floor = merchant_agent._effective_floor_price(product)
520
+ floor_str = str(effective_floor) if effective_floor is not None else ""
518
521
  # 泄漏守卫对每个 merchant 决策运行——ask/decline 等无 proposal 的决策
519
522
  # 也可能携带泄露底价的 public_message(此前该分支直接放行)。
520
523
  if floor_str and _leaks_private_threshold(str(decision.get("public_message") or ""), floor_str):
@@ -533,8 +536,8 @@ def _merchant_gate(
533
536
  if age > protocol.STOCK_OBSERVATION_MAX_AGE_SECONDS or age < -60:
534
537
  return _reject("stale_inventory", "库存观察时间已过期,请重新获取快照后再报价。")
535
538
  unit_price = float(proposal["unit_price"])
536
- if floor_str:
537
- if unit_price < float(floor_str):
539
+ if effective_floor is not None:
540
+ if unit_price < effective_floor:
538
541
  return _human("below_floor", "报价低于商家授权的自动磋商范围,需要人工处理。")
539
542
  elif unit_price < float(product["price"]):
540
543
  return _human("unauthorized_discount", "该折扣没有商家授权规则,需要人工处理。")
@@ -8,9 +8,11 @@ writing, mutating shared state, or changing turn/state transitions.
8
8
 
9
9
  The merchant gate stays in :mod:`shopping_cli.services.negotiation`: it reads
10
10
  the merchant automation boundary floor through
11
- ``merchant_agent._authorized_bargain_amount`` — a cross-module private access
12
- that a pure leaf must not own. The facade imports ``check_proposal_facts``
13
- from here so the merchant gate keeps calling the identical shared check.
11
+ ``merchant_agent._effective_floor_price`` (structured ``floor_price`` /
12
+ ``max_discount_percent`` first, free-text ``automation_boundaries`` fallback) —
13
+ a cross-module private access that a pure leaf must not own. The facade imports
14
+ ``check_proposal_facts`` from here so the merchant gate keeps calling the
15
+ identical shared check.
14
16
  """
15
17
 
16
18
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shopping-cli
3
- Version: 3.2.4
3
+ Version: 3.2.5
4
4
  Summary: Standalone local-commerce AI consultation marketplace runtime
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
File without changes
File without changes
File without changes
File without changes