From 2b0c35220ae6e8c54b23433c079d71c21420d64b Mon Sep 17 00:00:00 2001 From: selfrelease Date: Wed, 15 Jul 2026 11:37:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=20+=20AI=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E8=AF=A6=E6=83=85=E7=94=9F=E6=88=90=E8=8C=83?= =?UTF-8?q?=E5=BC=8F=E5=AF=B9=E8=AF=9D=20+=20=E4=B8=80=E9=94=AE=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.sql: 新增 product 表(positioning/selling_points) + deal 表加 product_id + 序列重置 + ALTER TABLE 迁移 - server.py: 产品 CRUD API + deal 关联 product_id + 仪表盘产品线统计 + 范式对话动态读取产品详情 - index.html: 产品管理页面 + 录入成交产品下拉 + 仪表盘增强 + 范式对话产品选择 + 导航通用化 - run_demo.sh: python 改 python3 + 预置成交关联 product_id - deploy.sh: 一键远程部署脚本(rsync+ssh+重启) - deploy_remote.sh: 远程数据库迁移脚本 --- demo/db/schema.sql | 69 ++++++++ demo/deploy_remote.sh | 50 ++++++ demo/run_demo.sh | 46 ++--- demo/web/server.py | 291 ++++++++++++++++++++++++++++-- demo/web/static/index.html | 350 +++++++++++++++++++++++++++++++++++-- deploy.sh | 90 ++++++++++ 6 files changed, 843 insertions(+), 53 deletions(-) create mode 100644 demo/deploy_remote.sh create mode 100644 deploy.sh diff --git a/demo/db/schema.sql b/demo/db/schema.sql index d403815..ad9ec9b 100644 --- a/demo/db/schema.sql +++ b/demo/db/schema.sql @@ -80,12 +80,31 @@ CREATE TABLE IF NOT EXISTS customer ( CREATE INDEX IF NOT EXISTS idx_customer_salesperson ON customer(salesperson_id); CREATE INDEX IF NOT EXISTS idx_customer_intent ON customer(intent_level); +-- 产品 +CREATE TABLE IF NOT EXISTS product ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + spec TEXT, + price NUMERIC(12,2), + description TEXT, + positioning TEXT, + selling_points TEXT[], + target_audience TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_product_category ON product(category); +CREATE INDEX IF NOT EXISTS idx_product_status ON product(status); + -- 成交记录 CREATE TABLE IF NOT EXISTS deal ( id SERIAL PRIMARY KEY, salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE, customer_id INT REFERENCES customer(id) ON DELETE SET NULL, contact_id INT REFERENCES contact(id) ON DELETE SET NULL, + product_id INT REFERENCES product(id) ON DELETE SET NULL, product_name TEXT NOT NULL, amount NUMERIC(12,2) NOT NULL, deal_date DATE NOT NULL, @@ -97,6 +116,53 @@ CREATE TABLE IF NOT EXISTS deal ( CREATE INDEX IF NOT EXISTS idx_deal_salesperson ON deal(salesperson_id); CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal(customer_id); CREATE INDEX IF NOT EXISTS idx_deal_date ON deal(deal_date); +CREATE INDEX IF NOT EXISTS idx_deal_product ON deal(product_id); + +-- 迁移:为已存在的 deal 表补充 product_id 列(首次执行时若列已存在则跳过) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'deal' AND column_name = 'product_id') THEN + ALTER TABLE deal ADD COLUMN product_id INT REFERENCES product(id) ON DELETE SET NULL; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'product' AND column_name = 'positioning') THEN + ALTER TABLE product ADD COLUMN positioning TEXT; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'product' AND column_name = 'selling_points') THEN + ALTER TABLE product ADD COLUMN selling_points TEXT[]; + END IF; +END $$; + +-- 预置产品数据(幂等) +INSERT INTO product (id, name, category, spec, price, description, positioning, selling_points, target_audience) VALUES + (1, '益童宝益生菌', '儿童益生菌', '3盒套餐', 798.00, + '丹麦进口益生菌粉,专为3-12岁儿童设计。采用科汉森DDS-1菌株,每袋含100亿活菌。主打调节肠道菌群、增强免疫力,对小儿湿疹、过敏性鼻炎、食物过敏等有显著改善效果。3盒为一个完整调理周期,建议持续服用3个月。', + '进口菌株儿童抗过敏专家,宝妈信赖之选', + ARRAY['丹麦进口科汉森DDS-1菌株,品质有保障', '每袋100亿活菌,活性稳定易吸收', '临床验证对湿疹、鼻炎、食物过敏改善率超85%', '3盒完整周期,从根源调节免疫', '无添加无副作用,儿童长期服用安全'], + '3-12岁儿童,尤其适合过敏体质儿童'), + (2, '益童宝益生菌', '儿童益生菌', '单盒', 298.00, + '丹麦进口益生菌粉,专为3-12岁儿童设计。采用科汉森DDS-1菌株,每袋含100亿活菌。主打调节肠道菌群、增强免疫力,对小儿湿疹、过敏性鼻炎、食物过敏等有显著改善效果。单盒15袋,半月用量,适合初次体验。', + '进口菌株儿童抗过敏专家,宝妈信赖之选', + ARRAY['丹麦进口科汉森DDS-1菌株,品质有保障', '每袋100亿活菌,活性稳定易吸收', '临床验证对湿疹、鼻炎、食物过敏改善率超85%', '单盒体验装,低门槛试用'], + '3-12岁儿童,尤其适合过敏体质儿童'), + (3, '益童宝益生菌', '儿童益生菌', '6盒套餐', 1499.00, + '丹麦进口益生菌粉,专为3-12岁儿童设计。采用科汉森DDS-1菌株,每袋含100亿活菌。6盒为两个完整调理周期,适合顽固性过敏体质儿童长期调理,性价比最高。', + '进口菌株儿童抗过敏专家,宝妈信赖之选', + ARRAY['丹麦进口科汉森DDS-1菌株,品质有保障', '每袋100亿活菌,活性稳定易吸收', '临床验证对湿疹、鼻炎、食物过敏改善率超85%', '6盒双周期,适合顽固性过敏长期调理', '均价最低,性价比之王'], + '3-12岁儿童,尤其适合顽固性过敏体质'), + (4, '纤益益生菌', '女性减肥益生菌', '2盒周期装', 596.00, + '专为18-35岁年轻女性设计的体重管理益生菌。含专利菌株B420,经临床验证可减少体脂率、腰围。配合膳食纤维和绿茶提取物,促进代谢、抑制脂肪吸收。2盒为一个周期(60天),建议配合轻度运动效果更佳。', + '科学减脂不反弹,年轻女性的身材管理专家', + ARRAY['专利菌株B420,临床验证减脂效果', '抑制脂肪吸收,促进肠道代谢', '配合膳食纤维+绿茶提取物,三效合一', '不节食不腹泻,健康减脂', '2盒完整周期,见效明显'], + '18-35岁有体重管理需求的年轻女性'), + (5, '纤益益生菌', '女性减肥益生菌', '单盒', 328.00, + '专为18-35岁年轻女性设计的体重管理益生菌。含专利菌株B420,经临床验证可减少体脂率、腰围。单盒30天用量,适合初次体验。', + '科学减脂不反弹,年轻女性的身材管理专家', + ARRAY['专利菌株B420,临床验证减脂效果', '抑制脂肪吸收,促进肠道代谢', '单盒体验装,低门槛试用', '不节食不腹泻,健康减脂'], + '18-35岁有体重管理需求的年轻女性') +ON CONFLICT (id) DO NOTHING; + +-- 重置 product 序列,避免显式 ID 插入后 SERIAL 不同步 +SELECT setval('product_id_seq', COALESCE((SELECT max(id) FROM product), 1), true); -- 预置 3 名销售人员(幂等) INSERT INTO salesperson (id, name, team, wx_account, device_id) VALUES @@ -104,3 +170,6 @@ INSERT INTO salesperson (id, name, team, wx_account, device_id) VALUES (2, '李娜', '华东团队', 'wxid_lina', 'macbook-ln-002'), (3, '王强', '华南团队', 'wxid_wangqiang', 'macbook-wq-003') ON CONFLICT (id) DO NOTHING; + +-- 重置 salesperson 序列 +SELECT setval('salesperson_id_seq', COALESCE((SELECT max(id) FROM salesperson), 1), true); diff --git a/demo/deploy_remote.sh b/demo/deploy_remote.sh new file mode 100644 index 0000000..3cf56ee --- /dev/null +++ b/demo/deploy_remote.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -eu + +# ============================================ +# 远程服务器端部署脚本(由 deploy.sh 调用) +# 在远程服务器上执行数据库迁移和数据预置 +# ============================================ + +DEMO_DIR="/opt/wxsales/demo" + +# 查找 psql 路径 +PSQL="" +for p in /usr/bin/psql /usr/local/bin/psql /usr/pgsql-17/bin/psql /usr/local/pg17/bin/psql; do + if [ -x "$p" ]; then PSQL="$p"; break; fi +done +if [ -z "$PSQL" ]; then + echo "[ERROR] 未找到 psql" + exit 1 +fi + +# 查找 PG socket 目录 +SOCKET_DIR="" +for d in /var/lib/pgsql/data /opt/wxsales/demo/db/socket /tmp; do + if [ -S "$d/.s.PGSQL.5434" ]; then SOCKET_DIR="$d"; break; fi +done + +# 构建连接参数 +if [ -n "$SOCKET_DIR" ]; then + PSQL_CMD="$PSQL -h $SOCKET_DIR -p 5434 -d wxchat_sales" +else + # 尝试默认连接 + PSQL_CMD="$PSQL -p 5434 -d wxchat_sales" +fi + +echo "[INFO] 使用 psql: $PSQL" +echo "[INFO] 执行 schema.sql..." + +# 执行 schema.sql(幂等,包含迁移语句) +$PSQL_CMD -f "$DEMO_DIR/db/schema.sql" 2>&1 || { + echo "[WARN] schema.sql 执行有警告,尝试继续..." +} + +# 重置序列 +$PSQL_CMD -c "SELECT setval('product_id_seq', COALESCE((SELECT max(id) FROM product), 1), true);" 2>/dev/null || true +$PSQL_CMD -c "SELECT setval('salesperson_id_seq', COALESCE((SELECT max(id) FROM salesperson), 1), true);" 2>/dev/null || true + +# 验证 +PRODUCT_COUNT=$($PSQL_CMD -t -c "SELECT count(*) FROM product;" 2>/dev/null | xargs || echo "0") +echo "[OK] 产品数量: $PRODUCT_COUNT" +echo "[OK] 数据库迁移完成" diff --git a/demo/run_demo.sh b/demo/run_demo.sh index 88c2511..216d520 100755 --- a/demo/run_demo.sh +++ b/demo/run_demo.sh @@ -30,7 +30,7 @@ bash "$ROOT/db/init_db.sh" echo "" echo "[2/5] 运行模拟采集代理..." for sp_id in 1 2 3; do - python "$ROOT/agent/mock_sync.py" --salesperson-id $sp_id + python3 "$ROOT/agent/mock_sync.py" --salesperson-id $sp_id done # 4. AI 分析 @@ -38,10 +38,10 @@ echo "" echo "[3/5] AI 分析..." if [ -n "$DASHSCOPE_API_KEY" ]; then echo " 使用千问 LLM 模式" - python "$ROOT/ai/analyze.py" --mode llm + python3 "$ROOT/ai/analyze.py" --mode llm else echo " 未设置 DASHSCOPE_API_KEY,使用规则模式" - python "$ROOT/ai/analyze.py" --mode rule + python3 "$ROOT/ai/analyze.py" --mode rule fi # 5. 预置成交数据 @@ -56,43 +56,45 @@ WHERE d1.id > d2.id AND d1.customer_id = d2.customer_id AND d1.product_name = d2.product_name AND d1.deal_date = d2.deal_date; +-- 清理旧格式产品名数据(不带产品线前缀的旧名称) +DELETE FROM deal WHERE product_name IN ('益童宝3盒套餐', '益童宝6盒套餐', '益童宝单盒'); -- 预置成交(用 NOT EXISTS 防重复) -INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status) -SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-05', 'closed' +INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status) +SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-07-05', 'closed' FROM salesperson s, customer cu WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '辰辰%' -AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-05'); +AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-07-05'); -INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status) -SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-08', 'closed' +INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status) +SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-07-08', 'closed' FROM salesperson s, customer cu WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '依依%' -AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-08'); +AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-07-08'); -INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status) -SELECT s.id, cu.id, '益童宝6盒套餐', 1499, '2026-07-12', 'closed' +INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status) +SELECT s.id, cu.id, 3, '益童宝益生菌 6盒套餐', 1499, '2026-07-12', 'closed' FROM salesperson s, customer cu WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '糖糖%' -AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝6盒套餐' AND deal_date = '2026-07-12'); +AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 6盒套餐' AND deal_date = '2026-07-12'); -INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status) -SELECT s.id, cu.id, '益童宝单盒', 298, '2026-07-10', 'closed' +INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status) +SELECT s.id, cu.id, 2, '益童宝益生菌 单盒', 298, '2026-07-10', 'closed' FROM salesperson s, customer cu WHERE s.id = 2 AND cu.salesperson_id = 2 AND cu.customer_name LIKE '甜甜%' -AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝单盒' AND deal_date = '2026-07-10'); +AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 单盒' AND deal_date = '2026-07-10'); -INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status) -SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-06', 'closed' +INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status) +SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-07-06', 'closed' FROM salesperson s, customer cu WHERE s.id = 3 AND cu.salesperson_id = 3 AND cu.customer_name LIKE '阳阳%' -AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-06'); +AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-07-06'); -INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status) -SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-06-20', 'closed' +INSERT INTO deal (salesperson_id, customer_id, product_id, product_name, amount, deal_date, status) +SELECT s.id, cu.id, 1, '益童宝益生菌 3盒套餐', 798, '2026-06-20', 'closed' FROM salesperson s, customer cu WHERE s.id = 3 AND cu.salesperson_id = 3 AND cu.customer_name LIKE '糖糖%' -AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-06-20'); +AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝益生菌 3盒套餐' AND deal_date = '2026-06-20'); " 2>/dev/null || true DEAL_COUNT=$($PSQL -t -c "SELECT count(*) FROM deal") @@ -108,7 +110,7 @@ if lsof -ti:8770 >/dev/null 2>&1; then sleep 1 fi -nohup python -m uvicorn web.server:app --port 8770 --host 127.0.0.1 \ +nohup python3 -m uvicorn web.server:app --port 8770 --host 127.0.0.1 \ > "$ROOT/web.log" 2>&1 & echo $! > "$PID_FILE" sleep 2 diff --git a/demo/web/server.py b/demo/web/server.py index 22c24dd..c75e555 100644 --- a/demo/web/server.py +++ b/demo/web/server.py @@ -75,6 +75,7 @@ class DealCreate(BaseModel): salesperson_id: int customer_id: int | None = None contact_id: int | None = None + product_id: int | None = None product_name: str amount: float deal_date: str @@ -82,6 +83,32 @@ class DealCreate(BaseModel): status: str = "closed" +class ProductCreate(BaseModel): + """新增产品请求模型。""" + name: str + category: str + spec: str | None = None + price: float | None = None + description: str | None = None + positioning: str | None = None + selling_points: list[str] | None = None + target_audience: str | None = None + status: str = "active" + + +class ProductUpdate(BaseModel): + """编辑产品请求模型。""" + name: str | None = None + category: str | None = None + spec: str | None = None + price: float | None = None + description: str | None = None + positioning: str | None = None + selling_points: list[str] | None = None + target_audience: str | None = None + status: str | None = None + + class AnalyzeRequest(BaseModel): mode: str = "rule" force: bool = False @@ -134,11 +161,33 @@ def dashboard(): for r in cur.fetchall() ] + # 按产品线统计本月销售额 + cur.execute(""" + SELECT p.category, + COALESCE(sum(d.amount), 0) AS amount, + count(*) AS deal_count + FROM deal d + LEFT JOIN product p ON p.id = d.product_id + WHERE d.status = 'closed' + AND d.deal_date >= date_trunc('month', now()) + GROUP BY p.category + ORDER BY amount DESC + """) + product_stats = [ + { + "category": r[0] or "未分类", + "amount": float(r[1]), + "deal_count": r[2], + } + for r in cur.fetchall() + ] + return { "total_messages": total_messages, "active_customers": active_customers, "monthly_deal_amount": monthly_deal_amount, "salespersons": salespersons, + "product_stats": product_stats, } finally: put_conn(conn) @@ -371,14 +420,23 @@ def create_deal(deal: DealCreate): if row: contact_id = row[0] + # 如果有 product_id 但没有 product_name,从 product 表获取 + product_name = deal.product_name + if deal.product_id and not product_name: + cur.execute("SELECT name, spec FROM product WHERE id = %s", (deal.product_id,)) + prow = cur.fetchone() + if prow: + product_name = f"{prow[0]} {prow[1]}" if prow[1] else prow[0] + cur.execute(""" - INSERT INTO deal (salesperson_id, customer_id, contact_id, product_name, + INSERT INTO deal (salesperson_id, customer_id, contact_id, product_id, product_name, amount, deal_date, status, notes) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( deal.salesperson_id, deal.customer_id, contact_id, - deal.product_name, deal.amount, deal.deal_date, + deal.product_id, product_name, + deal.amount, deal.deal_date, deal.status, deal.notes, )) deal_id = cur.fetchone()[0] @@ -394,6 +452,7 @@ def create_deal(deal: DealCreate): @app.get("/api/deals") def list_deals( salesperson_id: int | None = Query(None), + product_id: int | None = Query(None), start_date: str | None = Query(None), end_date: str | None = Query(None), ): @@ -403,7 +462,7 @@ def list_deals( sql = """ SELECT d.id, d.salesperson_id, s.name AS salesperson_name, d.customer_id, cu.customer_name, - d.product_name, d.amount, d.deal_date, d.status, d.notes + d.product_id, d.product_name, d.amount, d.deal_date, d.status, d.notes FROM deal d JOIN salesperson s ON s.id = d.salesperson_id LEFT JOIN customer cu ON cu.id = d.customer_id @@ -413,6 +472,9 @@ def list_deals( if salesperson_id is not None: conditions.append("d.salesperson_id = %s") params.append(salesperson_id) + if product_id is not None: + conditions.append("d.product_id = %s") + params.append(product_id) if start_date: conditions.append("d.deal_date >= %s") params.append(start_date) @@ -428,10 +490,11 @@ def list_deals( "id": r[0], "salesperson_id": r[1], "salesperson_name": r[2], "customer_id": r[3], "customer_name": r[4], - "product_name": r[5], - "amount": float(r[6]), - "deal_date": r[7].isoformat() if r[7] else None, - "status": r[8], "notes": r[9], + "product_id": r[5], + "product_name": r[6], + "amount": float(r[7]), + "deal_date": r[8].isoformat() if r[8] else None, + "status": r[9], "notes": r[10], } for r in cur.fetchall() ] @@ -439,6 +502,155 @@ def list_deals( put_conn(conn) +# --------------------------------------------------------------------------- +# 产品管理 API +# --------------------------------------------------------------------------- + +@app.get("/api/products") +def list_products( + category: str | None = Query(None), + status: str | None = Query(None), +): + """获取产品列表,支持按产品线和状态筛选。""" + conn = get_conn() + try: + with conn.cursor() as cur: + sql = """ + SELECT p.id, p.name, p.category, p.spec, p.price, + p.description, p.positioning, p.selling_points, + p.target_audience, p.status, p.created_at, + COALESCE(ds.deal_count, 0) AS deal_count, + COALESCE(ds.total_amount, 0) AS total_amount + FROM product p + LEFT JOIN LATERAL ( + SELECT count(*) AS deal_count, COALESCE(sum(d.amount), 0) AS total_amount + FROM deal d WHERE d.product_id = p.id AND d.status = 'closed' + ) ds ON true + """ + conditions = [] + params = [] + if category: + conditions.append("p.category = %s") + params.append(category) + if status: + conditions.append("p.status = %s") + params.append(status) + if conditions: + sql += " WHERE " + " AND ".join(conditions) + sql += " ORDER BY p.category, p.id" + cur.execute(sql, params) + return [ + { + "id": r[0], "name": r[1], "category": r[2], + "spec": r[3], "price": float(r[4]) if r[4] else None, + "description": r[5], "positioning": r[6], + "selling_points": parse_pg_array(r[7]), + "target_audience": r[8], + "status": r[9], + "created_at": r[10].isoformat() if r[10] else None, + "deal_count": r[11], + "total_amount": float(r[12]), + } + for r in cur.fetchall() + ] + finally: + put_conn(conn) + + +@app.post("/api/products") +def create_product(product: ProductCreate): + """新增产品。""" + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO product (name, category, spec, price, description, positioning, selling_points, target_audience, status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + product.name, product.category, product.spec, + product.price, product.description, product.positioning, + product.selling_points, product.target_audience, + product.status, + )) + product_id = cur.fetchone()[0] + conn.commit() + return {"id": product_id, "message": "产品已创建"} + except Exception: + conn.rollback() + raise + finally: + put_conn(conn) + + +@app.put("/api/products/{product_id}") +def update_product(product_id: int, product: ProductUpdate): + """编辑产品信息。""" + conn = get_conn() + try: + with conn.cursor() as cur: + fields = [] + params = [] + for field in ["name", "category", "spec", "price", "description", "positioning", "selling_points", "target_audience", "status"]: + val = getattr(product, field) + if val is not None: + fields.append(f"{field} = %s") + params.append(val) + if not fields: + raise HTTPException(status_code=400, detail="没有需要更新的字段") + params.append(product_id) + cur.execute( + f"UPDATE product SET {', '.join(fields)} WHERE id = %s RETURNING id", + params, + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="产品不存在") + conn.commit() + return {"id": product_id, "message": "产品已更新"} + except Exception: + conn.rollback() + raise + finally: + put_conn(conn) + + +@app.delete("/api/products/{product_id}") +def delete_product(product_id: int): + """删除产品(软删除,设为 inactive)。""" + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute( + "UPDATE product SET status = 'inactive' WHERE id = %s RETURNING id", + (product_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="产品不存在") + conn.commit() + return {"id": product_id, "message": "产品已下架"} + except Exception: + conn.rollback() + raise + finally: + put_conn(conn) + + +@app.get("/api/products/categories") +def list_product_categories(): + """获取所有产品线(去重)。""" + conn = get_conn() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT DISTINCT category FROM product WHERE status = 'active' ORDER BY category" + ) + return [r[0] for r in cur.fetchall()] + finally: + put_conn(conn) + + @app.post("/api/analyze") def trigger_analyze(req: AnalyzeRequest): """触发 AI 分析(调用 analyze.py 脚本)。""" @@ -510,8 +722,11 @@ def list_conversations( @app.post("/api/conversations/{customer_id}/paradigm") -def generate_paradigm(customer_id: int): - """调用 LLM 生成标准范式对话。""" +def generate_paradigm( + customer_id: int, + product_id: int | None = Query(None), +): + """调用 LLM 生成标准范式对话,基于产品详情动态构建 prompt。""" import os as _os import urllib.request as _urllib import urllib.error as _urllib_err @@ -535,6 +750,53 @@ def generate_paradigm(customer_id: int): if not customer: raise HTTPException(status_code=404, detail="客户不存在") + # 如果未指定 product_id,尝试从客户成交记录中推断 + if product_id is None: + cur.execute(""" + SELECT d.product_id FROM deal d + WHERE d.customer_id = %s AND d.product_id IS NOT NULL + ORDER BY d.deal_date DESC LIMIT 1 + """, (customer_id,)) + deal_row = cur.fetchone() + if deal_row: + product_id = deal_row[0] + + # 从产品表获取产品详情;如果仍无 product_id,取所有 active 产品汇总 + product_info_text = "" + if product_id is not None: + cur.execute(""" + SELECT name, spec, price, description, positioning, + selling_points, target_audience, category + FROM product WHERE id = %s + """, (product_id,)) + prow = cur.fetchone() + if prow: + sp_list = parse_pg_array(prow[4]) + product_info_text = ( + f"### 主推产品:{prow[0]}({prow[1]})\n" + f"- 产品线:{prow[7]}\n" + f"- 价格:¥{float(prow[2]):.2f}\n" + f"- 定位:{prow[4] or '未设定'}\n" + f"- 目标人群:{prow[6] or '未设定'}\n" + f"- 产品描述:{prow[3] or '未设定'}\n" + f"- 核心卖点:\n" + "\n".join(f" {i+1}. {sp}" for i, sp in enumerate(sp_list)) + ) + else: + cur.execute(""" + SELECT name, spec, price, positioning, target_audience, category + FROM product WHERE status = 'active' ORDER BY category, id + """) + products = cur.fetchall() + if products: + lines = [] + for p in products: + lines.append( + f"- {p[0]}({p[1]})¥{float(p[2]):.2f} | {p[5]} | 定位:{p[3] or '未设定'} | 目标:{p[4] or '未设定'}" + ) + product_info_text = "### 全部在售产品\n" + "\n".join(lines) + else: + product_info_text = "暂无产品数据" + # 获取对话记录 cur.execute("SELECT contact_id FROM customer WHERE id = %s", (customer_id,)) contact_id = cur.fetchone()[0] @@ -570,10 +832,10 @@ def generate_paradigm(customer_id: int): objections_str = "、".join(parse_pg_array(objections)) if objections else "无" key_needs_str = "、".join(parse_pg_array(key_needs)) if key_needs else "未明确" - prompt = f"""你是一个微信销售对话分析专家。请基于以下真实销售对话,生成一套标准范式对话模板。 + prompt = f"""你是一个微信销售对话分析专家。请基于以下产品详情和真实销售对话,生成一套标准范式对话模板。 ## 产品背景 -益童宝儿童益生菌粉:丹麦进口菌株,主打小儿抗过敏(湿疹、鼻炎、食物过敏),298元/盒,3盒套餐798元,6盒套餐1499元。目标客户是宝妈。 +{product_info_text} ## 客户信息 - 客户: {customer_name} @@ -588,12 +850,13 @@ def generate_paradigm(customer_id: int): {dialog_text} ## 输出要求 -请基于上述真实对话,提炼并生成一套**标准范式对话模板**,即针对此类客户的最优销售话术流程。输出严格 JSON: +请基于上述产品详情(定位、卖点、目标人群)和真实对话,提炼并生成一套**标准范式对话模板**,即针对此类客户的最优销售话术流程。话术要自然融入产品的核心卖点和定位,不要生硬推销。输出严格 JSON: - customer_type: 客户类型描述(如"湿疹宝妈-价格敏感型") +- product_positioning: 对该客户的产品推荐理由(结合产品定位和客户需求,1-2句话) - stages: 按销售阶段排列的对话步骤数组,每个元素包含: - stage: 阶段名称(建立联系/需求发现/报价/异议处理/成交) - goal: 该阶段目标 - - sales_script: 销售话术(1-3句,具体可执行) + - sales_script: 销售话术(1-3句,具体可执行,自然融入产品卖点) - expected_response: 预期客户回应 - tips: 该阶段技巧提示 - key_techniques: 核心销售技巧总结(数组,每条一句话) diff --git a/demo/web/static/index.html b/demo/web/static/index.html index 26d17cd..5d8a9fd 100644 --- a/demo/web/static/index.html +++ b/demo/web/static/index.html @@ -17,8 +17,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-