caterium-app/public/core/catalog-pricing.js
pavlov346346-source 424bf8ce87
Compact clients and explicit timed menu promotions (#31)
Add compact accessible client summaries and menu discounts in percent or rubles with explicit durations. Preserve ordinary catalog prices and order line snapshots; derive current prices and expire promotions automatically without background writes. Keep fractional prices, legacy sale compatibility, and regression coverage. Full pull-request QA passed. Publication remains gated by full main QA and byte-for-byte production asset and UI verification.
2026-09-19 09:34:50 +03:00

113 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Catalog prices are derived, never overwritten by a timer. Stored order lines
keep their own price snapshots. Promotion dates are absolute UTC instants. */
(()=>{
'use strict';
const DAY=86400000,MAX_TIMER=2147480000;
const number=v=>typeof v==='number'?v:Number(String(v??'').trim().replace(',','.'));
const cents=v=>Math.round((v+Number.EPSILON)*100)/100;
const validPrice=v=>Number.isFinite(number(v))&&number(v)>=0;
const money=v=>`${Number(v).toLocaleString('ru-RU',{maximumFractionDigits:2})} ₽`;
const stamp=v=>typeof v==='string'&&v.trim()?Date.parse(v):NaN;
const $=id=>document.getElementById(id);
function basePrice(item){
const current=validPrice(item?.price)?cents(number(item.price)):0;
// Compatibility only: old records stored the discounted price in price.
return !item?.promotion&&validPrice(item?.oldPrice)&&number(item.oldPrice)>current?cents(number(item.oldPrice)):current;
}
function promotion(item){
if(item?.promotion&&typeof item.promotion==='object')return item.promotion;
const base=basePrice(item),current=validPrice(item?.price)?cents(number(item.price)):0;
return base>current?{type:'amount',value:cents(base-current),startsAt:null,endsAt:null}:null;
}
function quote(item,now=Date.now()){
const base=basePrice(item),p=promotion(item),value=number(p?.value);
const start=p?.startsAt==null?-Infinity:stamp(p.startsAt),end=p?.endsAt==null?Infinity:stamp(p.endsAt);
const valid=Boolean(p&&['percent','amount'].includes(p.type)&&Number.isFinite(value)&&value>0&&base>0&&value<=(p.type==='percent'?100:base)&&!Number.isNaN(start)&&!Number.isNaN(end)&&end>start);
const active=valid&&now>=start&&now<end;
return {base,price:active?cents(Math.max(0,base-(p.type==='percent'?base*value/100:value))):base,active,scheduled:valid&&now<start,expired:valid&&now>=end};
}
const price=(item,now)=>quote(item,now).price;
function linePrice(line,item,now){return line?.price!==undefined&&line.price!==null&&String(line.price).trim()!==''&&validPrice(line.price)?cents(number(line.price)):price(item,now)}
const badge=(item)=>quote(item).active?'<span class="ct-promotion-badge">Акция</span>':'';
const localTime=iso=>{const d=new Date(iso);if(!Number.isFinite(d.getTime()))return '';return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`};
let editorOriginal=null,getter=null,timer=0,lastSignature='';
function loadEditor(item){
const p=promotion(item),q=quote(item);
editorOriginal=p?{...p}:null;
if(!$('ctPromotionEnabled'))return;
$('boxPrice').value=basePrice(item);
$('ctPromotionEnabled').checked=Boolean(p&&!q.expired);
$('ctDiscountType').value=p?.type==='amount'?'amount':'percent';
$('ctDiscountValue').value=p?.value??'';
const keep=$('ctPromotionDuration').querySelector('[value="keep"]');
keep.hidden=!(p?.endsAt&&!q.expired);
$('ctPromotionDuration').value=p&&!q.expired?(p.endsAt?'keep':'none'):'7';
$('ctPromotionEnd').value=p?.endsAt?localTime(p.endsAt):'';
$('ctPromotionError').textContent='';
updateEditor();
}
function readEditor(now=Date.now()){
const base=number($('boxPrice')?.value);
if(!Number.isFinite(base)||base<0)return {error:'Укажите обычную цену: число не меньше нуля.',field:'boxPrice'};
if(!$('ctPromotionEnabled')?.checked)return {base:cents(base),promotion:null};
const type=$('ctDiscountType').value,raw=$('ctDiscountValue').value,value=number(raw);
if(!raw.trim()||!Number.isFinite(value)||cents(value)<=0)return {error:'Укажите скидку больше нуля.',field:'ctDiscountValue'};
if(base<=0||value>(type==='percent'?100:base))return {error:type==='percent'?'Скидка должна быть не больше 100%, а обычная цена — больше нуля.':'Скидка не может превышать обычную цену.',field:'ctDiscountValue'};
const duration=$('ctPromotionDuration').value;
let startsAt=editorOriginal?.startsAt||new Date(now).toISOString(),endsAt=null;
if(duration==='keep')endsAt=editorOriginal?.endsAt;
else if(duration==='custom'){
const rawEnd=$('ctPromotionEnd').value,t=new Date(rawEnd).getTime();
if(!rawEnd||!Number.isFinite(t))return {error:'Выберите дату и время окончания акции.',field:'ctPromotionEnd'};
endsAt=new Date(t).toISOString();startsAt=new Date(now).toISOString();
}else if(duration!=='none'){
if(!['1','7','14','30'].includes(duration))return {error:'Выберите срок акции.',field:'ctPromotionDuration'};
startsAt=new Date(now).toISOString();endsAt=new Date(now+Number(duration)*DAY).toISOString();
}
if(endsAt&&stamp(endsAt)<=now)return {error:'Акция уже закончилась. Выберите новый срок или выключите её.',field:'ctPromotionDuration'};
return {base:cents(base),promotion:{type,value:cents(value),startsAt,endsAt}};
}
function updateEditor(){
const enabled=$('ctPromotionEnabled')?.checked,fields=$('ctPromotionFields');if(!fields)return;
fields.hidden=!enabled;
$('ctPromotionEndLabel').hidden=$('ctPromotionDuration').value!=='custom';
const type=$('ctDiscountType').value;
$('ctDiscountCaption').textContent=type==='percent'?'Скидка, %':'Скидка, ₽';
$('ctDiscountValue').max=type==='percent'?'100':String(number($('boxPrice').value)||0);
const data=readEditor(),preview=$('ctPromotionPreview');
if(data.error){preview.textContent=enabled?data.error:'';return;}
const q=quote({price:data.base,promotion:data.promotion});
const ending=data.promotion?.endsAt?` До ${new Date(data.promotion.endsAt).toLocaleString('ru-RU',{day:'numeric',month:'long',hour:'2-digit',minute:'2-digit'})}. Затем снова ${money(data.base)}.`:' Без ограничения срока.';
preview.textContent=enabled?`Цена по акции: ${money(q.price)}.${ending}`:`Обычная цена: ${money(data.base)}.`;
}
function saveEditor(item){
const data=readEditor();
if(data.error){$('ctPromotionError').textContent=data.error;$(data.field)?.focus();return false;}
item.price=data.base;
if(data.promotion)item.promotion=data.promotion;else delete item.promotion;
delete item.oldPrice;delete item.sale;
$('ctPromotionError').textContent='';return true;
}
function refresh(){
clearTimeout(timer);timer=0;if(!getter)return;
const now=Date.now(),items=getter()||[];
const signature=JSON.stringify(items.map(i=>[String(i.id),price(i,now),quote(i,now).active]));
if(signature!==lastSignature){lastSignature=signature;window.dispatchEvent(new CustomEvent('caterium:catalog-prices-changed'));}
let next=Infinity;
for(const item of items){const p=promotion(item);for(const t of [stamp(p?.startsAt),stamp(p?.endsAt)])if(t>now)next=Math.min(next,t);}
if(Number.isFinite(next))timer=setTimeout(refresh,Math.min(MAX_TIMER,Math.max(1,next-Date.now()+10)));
}
function watch(catalogGetter){getter=catalogGetter;refresh();}
function boot(){
$('ctPromotionEditor')?.addEventListener('input',()=>{if($('ctPromotionError'))$('ctPromotionError').textContent='';updateEditor();});
$('ctPromotionEditor')?.addEventListener('change',updateEditor);
$('boxPrice')?.addEventListener('input',updateEditor);
window.addEventListener('sun:cloud-state-applied',refresh);
window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
window.addEventListener('sun:cloud-tenant-changing',()=>{clearTimeout(timer);timer=0;lastSignature='';editorOriginal=null;});
}
window.CateriumPricing=Object.freeze({basePrice,promotion,quote,price,linePrice,badge,loadEditor,saveEditor,refresh,watch});
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
})();