feat: payment plan (schedule) bound to analysis milestones — flexible per-client schemes (100%/50-50/staged), mark stage paid creates registry payment; backend whitelists payment_schedule

This commit is contained in:
wasrusgen 2026-05-31 01:14:24 +03:00
parent 9a349fe21e
commit 78b3e22eb6
3 changed files with 99 additions and 3 deletions

View File

@ -295,7 +295,7 @@ function renderMainPanel(){
renderPayments(); renderPayments();
} }
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();} else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
else if(mainTab==="payments"){p.innerHTML=`<div id="paymentsBox"></div>`;renderPayments();} else if(mainTab==="payments"){p.innerHTML=`<div id="planBox"></div><div id="paymentsBox"></div>`;renderPaymentPlan();renderPayments();}
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();} else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;renderTab();} else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;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})}); await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
renderClient();await loadProjects(); 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=`<div class="blk" style="margin-bottom:14px"><div style="font-size:13px;font-weight:700;margin-bottom:4px">💼 План оплаты</div><div style="font-size:12px;color:var(--muted);margin-bottom:12px">Выберите схему — система раскидает суммы от суммы сделки и привяжет к вехам анализа.</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="cp-btn cp-a" onclick="genSchedule('full')">100% предоплата</button>
<button class="cp-btn cp-a" onclick="genSchedule('half')">Аванс 50% + 50%</button>
<button class="cp-btn cp-a" onclick="genSchedule('staged')">Поэтапно (3 платежа)</button>
</div></div>`;
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=`<div class="blk" style="margin-bottom:14px">
<div style="display:flex;align-items:center;margin-bottom:6px"><span style="font-size:13px;font-weight:700">💼 План оплаты</span><span style="margin-left:auto;font-size:12px;color:var(--muted)">Оплачено ${money(paid)} из ${money(total)}</span><button class="cp-btn cp-r" style="margin-left:10px;padding:5px 10px" onclick="clearSchedule()">↻ Сменить</button></div>
${sch.map((s,i)=>{const isPaid=s.status==="paid";return `<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-top:1px solid var(--bg)">
<span style="width:24px;height:24px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;background:${isPaid?'#047857':'#F1F5F9'};color:${isPaid?'#fff':'#9ca3af'}">${isPaid?'✓':i+1}</span>
<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:13px">${esc(s.name)}</div><div style="font-size:11px;color:var(--muted)">Веха: ${esc(trigName(s.trigger))}${isPaid&&s.paid_at?` · оплачен ${fmtDate(s.paid_at)}`:''}</div></div>
<span style="font-weight:700;color:var(--primary);white-space:nowrap">${money(s.amount)}</span>
${isPaid?`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:4px 10px;border-radius:6px;white-space:nowrap">Оплачен</span>`:`<button class="cp-btn cp-a" style="padding:6px 11px;white-space:nowrap" onclick="markStagePaid(${i})">Отметить оплату</button>`}
</div>`}).join("")}
<div style="margin-top:10px;padding:9px 12px;background:linear-gradient(135deg,rgba(4,120,87,.05),transparent);border-radius:8px;font-size:11.5px;color:var(--muted)">📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».</div>
</div>`;
}
function renderPricing(){ function renderPricing(){
const box=document.getElementById("pricingBox");if(!box)return; const box=document.getElementById("pricingBox");if(!box)return;
const billing=(state.crm||{}).billing_type||"paid"; const billing=(state.crm||{}).billing_type||"paid";

View File

@ -645,7 +645,7 @@ def update_crm():
"pipeline": "lead", "deal_amount": 0, "paid_amount": 0, "pipeline": "lead", "deal_amount": 0, "paid_amount": 0,
"contact": "", "source": "", "note": "", "payments": [], "billing_type": "paid" "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] if k in data: crm[k] = data[k]
# paid_amount = сумма платежей (если есть реестр) # paid_amount = сумма платежей (если есть реестр)
if "payments" in crm and isinstance(crm["payments"], list): if "payments" in crm and isinstance(crm["payments"], list):

View File

@ -295,7 +295,7 @@ function renderMainPanel(){
renderPayments(); renderPayments();
} }
else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();} else if(mainTab==="pricing"){p.innerHTML=`<div id="pricingBox"></div>`;renderPricing();}
else if(mainTab==="payments"){p.innerHTML=`<div id="paymentsBox"></div>`;renderPayments();} else if(mainTab==="payments"){p.innerHTML=`<div id="planBox"></div><div id="paymentsBox"></div>`;renderPaymentPlan();renderPayments();}
else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();} else if(mainTab==="tasks"){p.innerHTML=`<div id="tasksBox"></div>`;renderTasks();}
else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;renderTab();} else if(mainTab==="analysis"){p.innerHTML=`<div class="tabs">${TABS.map(t=>`<div class="tab ${t.id===activeTab?'active':''} ${approved(t.id)?'done':''}" onclick="setTab('${t.id}')">${t.icon} ${t.name}</div>`).join("")}</div><div id="tabContent"></div>`;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})}); await fetch(`${API}/api/project/crm`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:current,billing_type:t})});
renderClient();await loadProjects(); 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=`<div class="blk" style="margin-bottom:14px"><div style="font-size:13px;font-weight:700;margin-bottom:4px">💼 План оплаты</div><div style="font-size:12px;color:var(--muted);margin-bottom:12px">Выберите схему — система раскидает суммы от суммы сделки и привяжет к вехам анализа.</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="cp-btn cp-a" onclick="genSchedule('full')">100% предоплата</button>
<button class="cp-btn cp-a" onclick="genSchedule('half')">Аванс 50% + 50%</button>
<button class="cp-btn cp-a" onclick="genSchedule('staged')">Поэтапно (3 платежа)</button>
</div></div>`;
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=`<div class="blk" style="margin-bottom:14px">
<div style="display:flex;align-items:center;margin-bottom:6px"><span style="font-size:13px;font-weight:700">💼 План оплаты</span><span style="margin-left:auto;font-size:12px;color:var(--muted)">Оплачено ${money(paid)} из ${money(total)}</span><button class="cp-btn cp-r" style="margin-left:10px;padding:5px 10px" onclick="clearSchedule()">↻ Сменить</button></div>
${sch.map((s,i)=>{const isPaid=s.status==="paid";return `<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-top:1px solid var(--bg)">
<span style="width:24px;height:24px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;background:${isPaid?'#047857':'#F1F5F9'};color:${isPaid?'#fff':'#9ca3af'}">${isPaid?'✓':i+1}</span>
<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:13px">${esc(s.name)}</div><div style="font-size:11px;color:var(--muted)">Веха: ${esc(trigName(s.trigger))}${isPaid&&s.paid_at?` · оплачен ${fmtDate(s.paid_at)}`:''}</div></div>
<span style="font-weight:700;color:var(--primary);white-space:nowrap">${money(s.amount)}</span>
${isPaid?`<span style="font-size:11px;font-weight:700;color:#047857;background:#ECFDF5;padding:4px 10px;border-radius:6px;white-space:nowrap">Оплачен</span>`:`<button class="cp-btn cp-a" style="padding:6px 11px;white-space:nowrap" onclick="markStagePaid(${i})">Отметить оплату</button>`}
</div>`}).join("")}
<div style="margin-top:10px;padding:9px 12px;background:linear-gradient(135deg,rgba(4,120,87,.05),transparent);border-radius:8px;font-size:11.5px;color:var(--muted)">📄 ТЗ выдаётся клиенту (печать + выгрузка) после полной оплаты — финальный этап «Выдача ТЗ».</div>
</div>`;
}
function renderPricing(){ function renderPricing(){
const box=document.getElementById("pricingBox");if(!box)return; const box=document.getElementById("pricingBox");if(!box)return;
const billing=(state.crm||{}).billing_type||"paid"; const billing=(state.crm||{}).billing_type||"paid";