diff --git a/Mokap/crm.html b/Mokap/crm.html index d2641a9..74881ea 100644 --- a/Mokap/crm.html +++ b/Mokap/crm.html @@ -306,51 +306,78 @@ async function setBilling(t){ renderClient();await loadProjects(); } // ── План оплаты (привязка к вехам анализа) ── -const PAY_TRIGGERS=[{key:"start",name:"Старт работы"},{key:"methods",name:"Согл. методологий"},{key:"canvas",name:"Согл. стратегии"},{key:"idef0",name:"Согл. модели IDEF0"},{key:"spec",name:"Выдача ТЗ"}]; -function trigName(k){const t=PAY_TRIGGERS.find(x=>x.key===k);return t?t.name:k;} -function genSchedule(type){ - const deal=(state.crm&&state.crm.deal_amount)||0; - if(!deal){alert("Сначала укажите сумму сделки (вкладка «Сделка» или «Ценообразование»).");return;} - let arr; - if(type==="full")arr=[{name:"Предоплата 100%",amount:deal,trigger:"start",status:"pending"}]; - else if(type==="half"){const a=Math.round(deal/2);arr=[{name:"Аванс 50%",amount:a,trigger:"start",status:"pending"},{name:"Постоплата 50%",amount:deal-a,trigger:"spec",status:"pending"}];} - else{const a=Math.round(deal*0.4),b=Math.round(deal*0.3);arr=[{name:"Аванс 40%",amount:a,trigger:"start",status:"pending"},{name:"Промежуточный 30%",amount:b,trigger:"idef0",status:"pending"},{name:"Финал 30%",amount:deal-a-b,trigger:"spec",status:"pending"}];} - arr.forEach((s,i)=>s.id="s"+i); - state.crm=state.crm||{};state.crm.payment_schedule=arr; - saveSchedule();renderClient(); +// ── Этапы оплаты — от прогресса клиента ────────────────── +// Этапы = 5 этапов анализа. Отмечаем оплату по мере прохождения. +// Правило: ТЗ (печать + выгрузка) только после 100% оплаты. +const CLIENT_STAGES=[ + {key:"interview", name:"Интервью", icon:"💬"}, + {key:"methods", name:"Методологии", icon:"🎯"}, + {key:"canvas", name:"Стратегия", icon:"📊"}, + {key:"idef0", name:"Функции IDEF0", icon:"🔧"}, + {key:"spec", name:"Выдача ТЗ", icon:"📋"}, +]; +function stagePayKey(k){return "pay_"+k;} +function stageIsDone(k){ + if(k==="interview") return (state.messages||[]).length>0; + if(k==="methods") return !!state.selection; + if(k==="canvas") return !!state.canvas; + if(k==="idef0") return !!(state.model); + if(k==="spec") return !!state.spec; + return false; } -async function saveSchedule(){await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payment_schedule:state.crm.payment_schedule})});await loadProjects();} -function clearSchedule(){if(!confirm("Сменить схему плана оплаты? Реестр фактических платежей не затрагивается."))return;state.crm.payment_schedule=[];saveSchedule();renderClient();} -function markStagePaid(i){ - const s=state.crm.payment_schedule[i];if(!s||s.status==="paid")return; - s.status="paid";s.paid_at=new Date().toISOString().slice(0,10); +function getStagePays(){return (state.crm&&state.crm.stage_payments)||{};} +async function markStagePaid(k){ + const deal=(state.crm&&state.crm.deal_amount)||0; + const pays=getStagePays(); + if(pays[k])return; + const amt=prompt(`Сумма оплаты за этап «${CLIENT_STAGES.find(s=>s.key===k).name}» (₽):`,deal>0?Math.round(deal/5):""); + if(!amt||isNaN(+amt))return; + const today=new Date().toISOString().slice(0,10); + pays[k]={amount:+amt,date:today}; + state.crm=state.crm||{}; + state.crm.stage_payments=pays; state.crm.payments=state.crm.payments||[]; - state.crm.payments.push({date:s.paid_at,amount:s.amount,note:"Этап: "+s.name}); - Promise.all([saveSchedule(),savePayments()]).then(()=>renderClient()); + state.crm.payments.push({date:today,amount:+amt,note:"Этап: "+CLIENT_STAGES.find(s=>s.key===k).name}); + await Promise.all([ + fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_payments:pays,payments:state.crm.payments})}), + loadProjects() + ]); + renderClient(); } function renderPaymentPlan(){ const box=document.getElementById("planBox");if(!box)return; - const sch=(state.crm&&state.crm.payment_schedule)||[]; - if(!sch.length){ - box.innerHTML=`
💼 План оплаты
Выберите схему — система раскидает суммы от суммы сделки и привяжет к вехам анализа.
-
- - - -
`; - return; - } - const total=sch.reduce((s,x)=>s+(x.amount||0),0); - const paid=sch.filter(x=>x.status==="paid").reduce((s,x)=>s+(x.amount||0),0); + const deal=(state.crm&&state.crm.deal_amount)||0; + const pays=getStagePays(); + const paidTotal=Object.values(pays).reduce((s,p)=>s+(p.amount||0),0); + const billing=(state.crm&&state.crm.billing_type)||"paid"; + const isUnlocked=billing==="free"||(deal>0&&paidTotal>=deal); box.innerHTML=`
-
💼 План оплатыОплачено ${money(paid)} из ${money(total)}
- ${sch.map((s,i)=>{const isPaid=s.status==="paid";return `
- ${isPaid?'✓':i+1} -
${esc(s.name)}
Веха: ${esc(trigName(s.trigger))}${isPaid&&s.paid_at?` · оплачен ${fmtDate(s.paid_at)}`:''}
- ${money(s.amount)} - ${isPaid?`Оплачен`:``} -
`}).join("")} -
📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».
+
+ 💼 Прогресс оплаты по этапам + ${deal>0?`Получено ${money(paidTotal)} из ${money(deal)}`:'⚠ Укажите сумму сделки во вкладке «Сделка»'} +
+ ${CLIENT_STAGES.map(s=>{ + const done=stageIsDone(s.key); + const paid=pays[s.key]; + return `
+ ${s.icon} +
+
${s.name}${done?' ✓ Выполнен':''}
+ ${paid?`
Оплачено ${money(paid.amount)} · ${fmtDate(paid.date)}
`:done?`
Этап выполнен — можно запросить оплату
`:`
Ожидается выполнение этапа
`} +
+ ${paid + ?`${money(paid.amount)}` + :done + ?`` + :`` + } +
`; + }).join("")} +
+ ${isUnlocked + ?`✅ Оплата получена. Клиент может скачать ТЗ и печатные документы.` + :`🔒 Правило: ТЗ (печать + выгрузка) выдаётся клиенту только после получения полной суммы — ${money(deal)}.`} +
`; } function renderPricing(){ diff --git a/backend/elena_app.py b/backend/elena_app.py index a50c0d4..d88ec76 100644 --- a/backend/elena_app.py +++ b/backend/elena_app.py @@ -645,7 +645,7 @@ def update_crm(): "pipeline": "lead", "deal_amount": 0, "paid_amount": 0, "contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid" } - for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule"): + for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule", "stage_payments"): if k in data: crm[k] = data[k] # paid_amount = сумма платежей (если есть реестр) if "payments" in crm and isinstance(crm["payments"], list): diff --git a/docs/crm.html b/docs/crm.html index d2641a9..74881ea 100644 --- a/docs/crm.html +++ b/docs/crm.html @@ -306,51 +306,78 @@ async function setBilling(t){ renderClient();await loadProjects(); } // ── План оплаты (привязка к вехам анализа) ── -const PAY_TRIGGERS=[{key:"start",name:"Старт работы"},{key:"methods",name:"Согл. методологий"},{key:"canvas",name:"Согл. стратегии"},{key:"idef0",name:"Согл. модели IDEF0"},{key:"spec",name:"Выдача ТЗ"}]; -function trigName(k){const t=PAY_TRIGGERS.find(x=>x.key===k);return t?t.name:k;} -function genSchedule(type){ - const deal=(state.crm&&state.crm.deal_amount)||0; - if(!deal){alert("Сначала укажите сумму сделки (вкладка «Сделка» или «Ценообразование»).");return;} - let arr; - if(type==="full")arr=[{name:"Предоплата 100%",amount:deal,trigger:"start",status:"pending"}]; - else if(type==="half"){const a=Math.round(deal/2);arr=[{name:"Аванс 50%",amount:a,trigger:"start",status:"pending"},{name:"Постоплата 50%",amount:deal-a,trigger:"spec",status:"pending"}];} - else{const a=Math.round(deal*0.4),b=Math.round(deal*0.3);arr=[{name:"Аванс 40%",amount:a,trigger:"start",status:"pending"},{name:"Промежуточный 30%",amount:b,trigger:"idef0",status:"pending"},{name:"Финал 30%",amount:deal-a-b,trigger:"spec",status:"pending"}];} - arr.forEach((s,i)=>s.id="s"+i); - state.crm=state.crm||{};state.crm.payment_schedule=arr; - saveSchedule();renderClient(); +// ── Этапы оплаты — от прогресса клиента ────────────────── +// Этапы = 5 этапов анализа. Отмечаем оплату по мере прохождения. +// Правило: ТЗ (печать + выгрузка) только после 100% оплаты. +const CLIENT_STAGES=[ + {key:"interview", name:"Интервью", icon:"💬"}, + {key:"methods", name:"Методологии", icon:"🎯"}, + {key:"canvas", name:"Стратегия", icon:"📊"}, + {key:"idef0", name:"Функции IDEF0", icon:"🔧"}, + {key:"spec", name:"Выдача ТЗ", icon:"📋"}, +]; +function stagePayKey(k){return "pay_"+k;} +function stageIsDone(k){ + if(k==="interview") return (state.messages||[]).length>0; + if(k==="methods") return !!state.selection; + if(k==="canvas") return !!state.canvas; + if(k==="idef0") return !!(state.model); + if(k==="spec") return !!state.spec; + return false; } -async function saveSchedule(){await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,payment_schedule:state.crm.payment_schedule})});await loadProjects();} -function clearSchedule(){if(!confirm("Сменить схему плана оплаты? Реестр фактических платежей не затрагивается."))return;state.crm.payment_schedule=[];saveSchedule();renderClient();} -function markStagePaid(i){ - const s=state.crm.payment_schedule[i];if(!s||s.status==="paid")return; - s.status="paid";s.paid_at=new Date().toISOString().slice(0,10); +function getStagePays(){return (state.crm&&state.crm.stage_payments)||{};} +async function markStagePaid(k){ + const deal=(state.crm&&state.crm.deal_amount)||0; + const pays=getStagePays(); + if(pays[k])return; + const amt=prompt(`Сумма оплаты за этап «${CLIENT_STAGES.find(s=>s.key===k).name}» (₽):`,deal>0?Math.round(deal/5):""); + if(!amt||isNaN(+amt))return; + const today=new Date().toISOString().slice(0,10); + pays[k]={amount:+amt,date:today}; + state.crm=state.crm||{}; + state.crm.stage_payments=pays; state.crm.payments=state.crm.payments||[]; - state.crm.payments.push({date:s.paid_at,amount:s.amount,note:"Этап: "+s.name}); - Promise.all([saveSchedule(),savePayments()]).then(()=>renderClient()); + state.crm.payments.push({date:today,amount:+amt,note:"Этап: "+CLIENT_STAGES.find(s=>s.key===k).name}); + await Promise.all([ + fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,stage_payments:pays,payments:state.crm.payments})}), + loadProjects() + ]); + renderClient(); } function renderPaymentPlan(){ const box=document.getElementById("planBox");if(!box)return; - const sch=(state.crm&&state.crm.payment_schedule)||[]; - if(!sch.length){ - box.innerHTML=`
💼 План оплаты
Выберите схему — система раскидает суммы от суммы сделки и привяжет к вехам анализа.
-
- - - -
`; - return; - } - const total=sch.reduce((s,x)=>s+(x.amount||0),0); - const paid=sch.filter(x=>x.status==="paid").reduce((s,x)=>s+(x.amount||0),0); + const deal=(state.crm&&state.crm.deal_amount)||0; + const pays=getStagePays(); + const paidTotal=Object.values(pays).reduce((s,p)=>s+(p.amount||0),0); + const billing=(state.crm&&state.crm.billing_type)||"paid"; + const isUnlocked=billing==="free"||(deal>0&&paidTotal>=deal); box.innerHTML=`
-
💼 План оплатыОплачено ${money(paid)} из ${money(total)}
- ${sch.map((s,i)=>{const isPaid=s.status==="paid";return `
- ${isPaid?'✓':i+1} -
${esc(s.name)}
Веха: ${esc(trigName(s.trigger))}${isPaid&&s.paid_at?` · оплачен ${fmtDate(s.paid_at)}`:''}
- ${money(s.amount)} - ${isPaid?`Оплачен`:``} -
`}).join("")} -
📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».
+
+ 💼 Прогресс оплаты по этапам + ${deal>0?`Получено ${money(paidTotal)} из ${money(deal)}`:'⚠ Укажите сумму сделки во вкладке «Сделка»'} +
+ ${CLIENT_STAGES.map(s=>{ + const done=stageIsDone(s.key); + const paid=pays[s.key]; + return `
+ ${s.icon} +
+
${s.name}${done?' ✓ Выполнен':''}
+ ${paid?`
Оплачено ${money(paid.amount)} · ${fmtDate(paid.date)}
`:done?`
Этап выполнен — можно запросить оплату
`:`
Ожидается выполнение этапа
`} +
+ ${paid + ?`${money(paid.amount)}` + :done + ?`` + :`` + } +
`; + }).join("")} +
+ ${isUnlocked + ?`✅ Оплата получена. Клиент может скачать ТЗ и печатные документы.` + :`🔒 Правило: ТЗ (печать + выгрузка) выдаётся клиенту только после получения полной суммы — ${money(deal)}.`} +
`; } function renderPricing(){