From 78b3e22eb6c37980cb432fe7ca557bab73de5ce0 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sun, 31 May 2026 01:14:24 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20payment=20plan=20(schedule)=20bound=20t?= =?UTF-8?q?o=20analysis=20milestones=20=E2=80=94=20flexible=20per-client?= =?UTF-8?q?=20schemes=20(100%/50-50/staged),=20mark=20stage=20paid=20creat?= =?UTF-8?q?es=20registry=20payment;=20backend=20whitelists=20payment=5Fsch?= =?UTF-8?q?edule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mokap/crm.html | 50 +++++++++++++++++++++++++++++++++++++++++++- backend/elena_app.py | 2 +- docs/crm.html | 50 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/Mokap/crm.html b/Mokap/crm.html index 6ba7956..d2641a9 100644 --- a/Mokap/crm.html +++ b/Mokap/crm.html @@ -295,7 +295,7 @@ function renderMainPanel(){ renderPayments(); } else if(mainTab==="pricing"){p.innerHTML=`
`;renderPricing();} - else if(mainTab==="payments"){p.innerHTML=`
`;renderPayments();} + else if(mainTab==="payments"){p.innerHTML=`
`;renderPaymentPlan();renderPayments();} else if(mainTab==="tasks"){p.innerHTML=`
`;renderTasks();} else if(mainTab==="analysis"){p.innerHTML=`
${TABS.map(t=>`
${t.icon} ${t.name}
`).join("")}
`;renderTab();} } @@ -305,6 +305,54 @@ async function setBilling(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(); } +// ── План оплаты (привязка к вехам анализа) ── +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(); +} +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); + 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()); +} +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); + 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("")} +
📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».
+
`; +} function renderPricing(){ const box=document.getElementById("pricingBox");if(!box)return; const billing=(state.crm||{}).billing_type||"paid"; diff --git a/backend/elena_app.py b/backend/elena_app.py index 567b173..a50c0d4 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"): + for k in ("pipeline", "deal_amount", "paid_amount", "contact", "source", "note", "payments", "billing_type", "payment_schedule"): 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 6ba7956..d2641a9 100644 --- a/docs/crm.html +++ b/docs/crm.html @@ -295,7 +295,7 @@ function renderMainPanel(){ renderPayments(); } else if(mainTab==="pricing"){p.innerHTML=`
`;renderPricing();} - else if(mainTab==="payments"){p.innerHTML=`
`;renderPayments();} + else if(mainTab==="payments"){p.innerHTML=`
`;renderPaymentPlan();renderPayments();} else if(mainTab==="tasks"){p.innerHTML=`
`;renderTasks();} else if(mainTab==="analysis"){p.innerHTML=`
${TABS.map(t=>`
${t.icon} ${t.name}
`).join("")}
`;renderTab();} } @@ -305,6 +305,54 @@ async function setBilling(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(); } +// ── План оплаты (привязка к вехам анализа) ── +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(); +} +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); + 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()); +} +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); + 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("")} +
📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».
+
`; +} function renderPricing(){ const box=document.getElementById("pricingBox");if(!box)return; const billing=(state.crm||{}).billing_type||"paid";