shopping-cli 3.2.3__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.
- {shopping_cli-3.2.3/shopping_cli.egg-info → shopping_cli-3.2.5}/PKG-INFO +1 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/pyproject.toml +1 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/__init__.py +1 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/merchant_agent.py +23 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/catalog.py +6 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/listings_projection.py +8 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli.py +24 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_adapter_commands.py +21 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_catalog_commands.py +39 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/catalog.py +222 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/catalog_views.py +4 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/db/migrations.py +26 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/db/models.py +9 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/listings/projection.py +13 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation.py +6 -3
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_gates.py +5 -3
- {shopping_cli-3.2.3 → shopping_cli-3.2.5/shopping_cli.egg-info}/PKG-INFO +1 -1
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/LICENSE +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/MANIFEST.in +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/README.md +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/setup.cfg +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/agent_cli.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/buyer_cli.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/merchant_daemon.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/merchant_state_helpers.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/agents/tools.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/app.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/auth.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/error_handlers.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/error_response.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/fallback_asgi.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/fastapi_routes.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/agents.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/audit.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/buyer.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/common.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/conversations.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/erp.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/human_review.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/handlers/negotiation.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/idempotency.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/limits.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/request_dispatch.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/request_limits.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/route_matching.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/route_registry.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/route_table.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/routes_agents.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/routes_conversations.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/routes_marketplace.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/routes_merchants.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/api/server.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_agent_commands.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_buyer_commands.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_common.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_conversation_commands.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_erp_commands.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/cli_listing_commands.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/commerce/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/commerce/adapters.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/commerce/data_source.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/config.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/contracts/kiwi-contracts.lock.json +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/capabilities.schema.json +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/decision.schema.json +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/policy-result.schema.json +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/contracts/shopping.negotiation/0.1/snapshot.schema.json +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/catalog_scoring.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/catalog_text.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/channels.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/conversations.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/delivery.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/errors.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/harness.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/limits.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/negotiation.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/negotiation_contracts.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/policies.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/risk.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/core/tokens.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/data_sources/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/data_sources/adapter.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/data_sources/csv_excel_source.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/data_sources/erp_source.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/db/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/db/provenance.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/db/session.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/http_client.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/listings/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/__init__.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/agents.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/audit.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/buyer_bootstrap.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/conversations.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/human_review.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_message.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_policy_helpers.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_policy_result.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_snapshot.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_snapshot_projection.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/tokens.py +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli.egg-info/SOURCES.txt +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli.egg-info/dependency_links.txt +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli.egg-info/entry_points.txt +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli.egg-info/requires.txt +0 -0
- {shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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")
|
|
@@ -866,7 +888,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
866
888
|
import_csv = subparsers.add_parser(
|
|
867
889
|
"import-csv-excel", help="Import product facts from CSV / Excel (.xlsx) into the local store"
|
|
868
890
|
)
|
|
869
|
-
import_csv.add_argument("--file",
|
|
891
|
+
import_csv.add_argument("--file", default="", help="Path to .csv / .tsv / .xlsx file (required unless --template)")
|
|
892
|
+
import_csv.add_argument("--template", default="", dest="template", help="写一份 CSV 模板到该路径后退出(不导入)")
|
|
870
893
|
import_csv.add_argument("--merchant", default="", dest="merchant", help="Owning merchant id (or SHOPPING_MERCHANT_ID / KIWI_MERCHANT_ID)")
|
|
871
894
|
import_csv.add_argument("--default-merchant", default="", dest="default_merchant", help=argparse.SUPPRESS)
|
|
872
895
|
import_csv.add_argument("--allowed-merchant", default="", dest="allowed_merchant", help="Tenant boundary: only rows of this merchant are written")
|
|
@@ -18,6 +18,14 @@ from shopping_cli.data_sources.adapter import (
|
|
|
18
18
|
)
|
|
19
19
|
from shopping_cli.db.session import db_session
|
|
20
20
|
|
|
21
|
+
# CSV/Excel 导入模板(与 examples/products-template.csv 一致)。必填列 sku/title/
|
|
22
|
+
# price/stock;可选 currency(缺省 CNY)/ category / description / merchant_id。
|
|
23
|
+
PRODUCTS_TEMPLATE_CSV = (
|
|
24
|
+
"sku,title,price,stock,currency,category,description\n"
|
|
25
|
+
"VQ-001,智能保温杯 500ml,129.00,50,CNY,kitchenware,316 不锈钢内胆真空保温\n"
|
|
26
|
+
"VQ-002,桌面无线充电器 15W,89.00,120,CNY,electronics,兼容 QC3.0\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
21
29
|
|
|
22
30
|
def cmd_adapters_list(args: argparse.Namespace) -> None:
|
|
23
31
|
"""列出已注册适配器(Adapter SDK 注册表)。"""
|
|
@@ -28,7 +36,19 @@ def cmd_adapters_list(args: argparse.Namespace) -> None:
|
|
|
28
36
|
|
|
29
37
|
|
|
30
38
|
def cmd_import_csv_excel(args: argparse.Namespace) -> None:
|
|
31
|
-
"""运行 CSV/Excel 适配器导入本地 products
|
|
39
|
+
"""运行 CSV/Excel 适配器导入本地 products 表;--template 只生成模板不导入。"""
|
|
40
|
+
template_path = str(getattr(args, "template", "") or "").strip()
|
|
41
|
+
if template_path:
|
|
42
|
+
with open(template_path, "w", encoding="utf-8", newline="") as fh:
|
|
43
|
+
fh.write(PRODUCTS_TEMPLATE_CSV)
|
|
44
|
+
emit(
|
|
45
|
+
{"ok": True, "message": f"CSV 模板已写入 {template_path}(填好 sku/title/price/stock 后再导入)"},
|
|
46
|
+
getattr(args, "format", "text"),
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
file_path = str(args.file or "").strip()
|
|
50
|
+
if not file_path:
|
|
51
|
+
raise SystemExit("--file <path> 必填(或先用 --template <path> 生成模板)")
|
|
32
52
|
db_path = db_path_from_args(args)
|
|
33
53
|
merchant_id = (
|
|
34
54
|
str(args.merchant or "").strip()
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
537
|
-
if unit_price <
|
|
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.
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_policy_helpers.py
RENAMED
|
File without changes
|
{shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_policy_result.py
RENAMED
|
File without changes
|
|
File without changes
|
{shopping_cli-3.2.3 → shopping_cli-3.2.5}/shopping_cli/services/negotiation_snapshot_projection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|