feat: billing type (paid/free) + AI pricing — scale assessment, market analysis, packages with arguments

This commit is contained in:
wasrusgen 2026-05-30 15:26:47 +03:00
parent a537edef3c
commit 4776b9a9e0
2 changed files with 102 additions and 4 deletions

View File

@ -634,9 +634,9 @@ def update_crm():
return jsonify({"error": "project not found"}), 404
crm = latest_artifact(proj["id"], "crm") or {
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
"contact": "", "source": "", "note": "", "payments": []
"contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid"
}
for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments"):
for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type"):
if k in data: crm[k] = data[k]
# paid_amount = сумма платежей (если есть реестр)
if "payments" in crm and isinstance(crm["payments"], list):
@ -924,6 +924,65 @@ def design_screen():
return jsonify({"error": usage}), 500
return jsonify({"screen": result, "usage": usage})
# ══ ЦЕНООБРАЗОВАНИЕ под клиента ══════════════════════
PRICING_TOOL = {
"name": "build_pricing",
"description": "Оценивает масштаб работы по клиенту, анализирует рынок консалтинга и предлагает таблицу цен с аргументами.",
"input_schema": {
"type": "object",
"properties": {
"scale": {
"type": "object",
"description": "Оценка масштаба работы",
"properties": {
"size": {"type": "string", "enum": ["micro", "small", "medium", "large"], "description": "micro=соло, small=2-10, medium=10-50, large=50+"},
"complexity": {"type": "string", "enum": ["low", "medium", "high"]},
"scope": {"type": "string", "description": "Объём работ: сколько ролей интервьюировать, процессов, документов"},
"effort_estimate": {"type": "string", "description": "Оценка трудозатрат (часы/недели) и токенов AI"}
},
"required": ["size", "complexity", "scope", "effort_estimate"]
},
"market": {"type": "string", "description": "Анализ рынка: вилка цен на аналогичный консалтинг в РФ, с кем сравниваем"},
"packages": {
"type": "array",
"description": "2-3 пакета услуг",
"items": {"type": "object", "properties": {
"name": {"type": "string"},
"scope": {"type": "array", "items": {"type": "string"}, "description": "Что входит"},
"price": {"type": "integer", "description": "Цена в рублях"},
"duration": {"type": "string", "description": "Срок"},
"argument": {"type": "string", "description": "Аргумент почему такая цена"}
}, "required": ["name", "scope", "price", "duration", "argument"]}
},
"recommended": {"type": "string", "description": "Какой пакет рекомендуется этому клиенту и почему"},
"rationale": {"type": "string", "description": "Главный аргумент по цене для продажи (ROI, экономия клиента)"}
},
"required": ["scale", "market", "packages", "recommended", "rationale"]
}
}
@app.route("/api/build-pricing", methods=["POST"])
def build_pricing():
data = request.get_json(force=True) or {}
proj = get_project(data.get("token"))
if not proj:
return jsonify({"error": "project not found"}), 404
# Контекст: интервью + IDEF0 модель если есть
model_row = db().execute("SELECT blocks_json FROM models WHERE project_id=? ORDER BY id DESC LIMIT 1", (proj["id"],)).fetchone()
extra = ("ПОСТРОЕННАЯ МОДЕЛЬ БИЗНЕСА (для оценки масштаба):\n" + model_row["blocks_json"]) if model_row else None
instr = ("На основе первичного интервью (и модели бизнеса, если есть) сформируй ЦЕНОВОЕ ПРЕДЛОЖЕНИЕ для клиента.\n"
"1. Оцени МАСШТАБ работы: размер бизнеса, сложность, сколько ролей интервьюировать, процессов, документов, оценка трудозатрат.\n"
"2. Проанализируй РЫНОК: вилка цен на аналогичный консалтинг (разбор бизнеса + ТЗ на ПО) в РФ для такого масштаба.\n"
"3. Предложи 2-3 ПАКЕТА (напр. Экспресс / Стандарт / Премиум или по этапам) с ценами в рублях, составом, сроком и аргументом цены.\n"
"4. Рекомендуй пакет под этого клиента.\n"
"5. Главный аргумент по цене — через ROI/экономию клиента (сколько он теряет сейчас vs стоимость).\n"
"Цены реалистичные для МСБ РФ. Вызови build_pricing.")
result, usage = run_tool(proj["id"], PRICING_TOOL, "build_pricing", instr, extra_context=extra, max_tokens=2500)
if result is None:
return jsonify({"error": usage}), 500
save_artifact(proj["id"], "pricing", result)
return jsonify({"pricing": result, "usage": usage})
@app.route("/api/project/<token>")
def get_project_state(token):
proj = get_project(token)
@ -947,8 +1006,9 @@ def get_project_state(token):
"canvas": latest_artifact(proj["id"], "canvas"),
"spec": latest_artifact(proj["id"], "spec"),
"approvals": latest_artifact(proj["id"], "approvals") or {},
"crm": latest_artifact(proj["id"], "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":""},
"crm": latest_artifact(proj["id"], "crm") or {"pipeline":"lead","deal_amount":0,"paid_amount":0,"contact":"","source":"","note":"","billing_type":"paid"},
"tasks": (latest_artifact(proj["id"], "tasks") or {}).get("items", []),
"pricing": latest_artifact(proj["id"], "pricing"),
"documents": [json.loads(r["data_json"]) and {"filename": json.loads(r["data_json"])["filename"], "size": json.loads(r["data_json"]).get("size",0)} for r in db().execute("SELECT data_json FROM artifacts WHERE project_id=? AND kind='document' ORDER BY id", (proj["id"],)).fetchall()]
})

View File

@ -218,8 +218,14 @@ function renderPipeline(){
function renderClient(){
const crm=state.crm||{pipeline:"lead",deal_amount:0,paid_amount:0,contact:"",source:"",note:""};
const billing=crm.billing_type||"paid";
document.getElementById("view").innerHTML=`
<div class="cc-top"><div class="cc-av">${esc((state.client_name||'?')[0])}</div><div><div class="cc-name">${esc(state.client_name||'Без имени')}</div><div class="cc-meta">${esc(state.niche||'')} · ${state.messages.length} сообщений</div></div></div>
<div class="cc-top"><div class="cc-av">${esc((state.client_name||'?')[0])}</div><div style="flex:1"><div class="cc-name">${esc(state.client_name||'Без имени')}</div><div class="cc-meta">${esc(state.niche||'')} · ${state.messages.length} сообщений</div></div>
<div style="display:flex;gap:4px;background:#F1F5F9;border-radius:10px;padding:3px">
<button onclick="setBilling('paid')" style="padding:7px 14px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:700;font-family:Inter;${billing==='paid'?'background:#047857;color:#fff':'background:transparent;color:#6B7280'}">💰 Платный</button>
<button onclick="setBilling('free')" style="padding:7px 14px;border-radius:8px;border:none;cursor:pointer;font-size:13px;font-weight:700;font-family:Inter;${billing==='free'?'background:#6366F1;color:#fff':'background:transparent;color:#6B7280'}">🎁 Бесплатный</button>
</div></div>
<div id="pricingBox"></div>
<div class="cc-grid">
<div class="cc-field"><div class="cc-fl">Статус воронки</div><select class="cc-sel" id="cpPipe" onchange="saveCrm()">${PIPE.map(([k,n])=>`<option value="${k}" ${crm.pipeline===k?'selected':''}>${n}</option>`).join("")}</select></div>
<div class="cc-field"><div class="cc-fl">Сумма сделки</div><input class="cc-fi" id="cpDeal" type="number" value="${crm.deal_amount||''}" placeholder="0" onchange="saveCrm()"></div>
@ -233,8 +239,40 @@ function renderClient(){
<div id="tabContent"></div>`;
renderTasks();
renderPayments();
renderPricing();
renderTab();
}
async function setBilling(t){
state.crm=state.crm||{};state.crm.billing_type=t;
await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
renderClient();await loadProjects();
}
function renderPricing(){
const box=document.getElementById("pricingBox");if(!box)return;
const billing=(state.crm||{}).billing_type||"paid";
if(billing==="free"){box.innerHTML=`<div class="blk" style="background:#EEF2FF;border-color:#C7D2FE;text-align:center;padding:16px"><b style="color:#6366F1">🎁 Бесплатный клиент</b> — пилот / демо / партнёрский. Ценообразование не применяется.</div>`;return}
const p=state.pricing;
if(!p){box.innerHTML=`<div class="run-card" style="margin:0 0 18px"><div class="run-ic">💰</div><div class="run-t">Ценовое предложение</div><div class="run-d">Елена оценит масштаб работы, проанализирует рынок и предложит пакеты с аргументами цены.</div><button class="run-btn" id="rb-pricing" onclick="buildPricing()">Рассчитать цену →</button></div>`;return}
const SZ={micro:"Микро",small:"Малый",medium:"Средний",large:"Крупный"};const CX={low:"низкая",medium:"средняя",high:"высокая"};
box.innerHTML=`<div class="blk" style="margin-bottom:14px"><div style="display:flex;align-items:center;margin-bottom:10px"><b style="font-size:14px">💰 Ценовое предложение</b><button class="cp-btn cp-r" style="margin-left:auto;padding:5px 10px" onclick="buildPricing()">↻ Пересчитать</button></div>
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap"><span style="font-size:12px;background:#F1F5F9;border-radius:6px;padding:4px 10px">Масштаб: <b>${SZ[p.scale.size]||p.scale.size}</b></span><span style="font-size:12px;background:#F1F5F9;border-radius:6px;padding:4px 10px">Сложность: <b>${CX[p.scale.complexity]||p.scale.complexity}</b></span><span style="font-size:12px;background:#F1F5F9;border-radius:6px;padding:4px 10px">${esc(p.scale.effort_estimate)}</span></div>
<div style="font-size:12px;color:var(--muted);margin-bottom:14px;padding:10px;background:var(--bg);border-radius:8px"><b>Рынок:</b> ${esc(p.market)}</div>
<div style="display:grid;grid-template-columns:repeat(${p.packages.length},1fr);gap:10px">${p.packages.map((pk,i)=>`<div style="border:1.5px solid ${p.recommended&&p.recommended.indexOf(pk.name)>=0?'var(--primary)':'var(--border)'};border-radius:12px;padding:14px;position:relative">${p.recommended&&p.recommended.indexOf(pk.name)>=0?'<span style="position:absolute;top:-9px;left:14px;background:var(--primary);color:#fff;font-size:9px;font-weight:800;padding:2px 8px;border-radius:10px">РЕКОМЕНДУЮ</span>':''}<div style="font-size:14px;font-weight:800;font-family:Montserrat;margin-bottom:4px">${esc(pk.name)}</div><div style="font-size:22px;font-weight:800;color:var(--primary);margin-bottom:2px">${money(pk.price)}</div><div style="font-size:11px;color:#9ca3af;margin-bottom:8px">${esc(pk.duration)}</div><div style="margin-bottom:8px">${pk.scope.map(s=>`<div style="font-size:11.5px;color:#374151;padding-left:14px;position:relative;margin-bottom:3px;line-height:1.35"><span style="position:absolute;left:2px;color:var(--mid)"></span>${esc(s)}</div>`).join("")}</div><div style="font-size:11px;color:#6b7280;font-style:italic;border-top:1px solid var(--bg);padding-top:6px">${esc(pk.argument)}</div><button class="cp-btn cp-a" style="width:100%;margin-top:8px" onclick="applyPrice(${pk.price})">Выбрать → ${money(pk.price)}</button></div>`).join("")}</div>
<div style="margin-top:14px;padding:12px 14px;background:linear-gradient(135deg,rgba(4,120,87,.06),rgba(16,185,129,.04));border:1px solid rgba(4,120,87,.15);border-radius:10px;font-size:13px;line-height:1.5"><b style="color:var(--primary)">Аргумент для клиента:</b> ${esc(p.rationale)}</div>
</div>`;
}
async function buildPricing(){
const btn=document.getElementById("rb-pricing");if(btn){btn.disabled=true;btn.innerHTML='<span class="spin"></span> Елена анализирует рынок...'}
try{const r=await fetch(`${API}/api/build-pricing`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current})});const d=await r.json();
if(d.error){alert("Ошибка: "+d.error);if(btn){btn.disabled=false;btn.textContent="Повторить"}return}
state.pricing=d.pricing;renderPricing();
}catch(e){alert("Ошибка: "+e.message);if(btn)btn.disabled=false}
}
function applyPrice(price){
state.crm=state.crm||{};state.crm.deal_amount=price;
document.getElementById("cpDeal").value=price;
fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,deal_amount:price})}).then(()=>{loadProjects();renderPayments();alert("Сумма сделки установлена: "+money(price))});
}
function renderPayments(){
const crm=state.crm||{};const deal=crm.deal_amount||0;const pays=crm.payments||[];
const paid=pays.reduce((s,p)=>s+(p.amount||0),0);const left=deal-paid;