From 86bd8b8f6a4ce1a422b46f0d04be2fdf96e8a18b Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Wed, 9 Sep 2026 10:21:15 +0300 Subject: [PATCH] Add v17.7.2 client data layer foundation --- public/core/data-layer-v1772.js | 172 ++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 public/core/data-layer-v1772.js diff --git a/public/core/data-layer-v1772.js b/public/core/data-layer-v1772.js new file mode 100644 index 0000000..8151fdf --- /dev/null +++ b/public/core/data-layer-v1772.js @@ -0,0 +1,172 @@ +(()=>{ + 'use strict'; + if(window.CateriumDataV1772)return; + + const VERSION='17.7.2'; + const RELEASE='20260909-v17-7-2-clients-foundation'; + const CLIENT_CACHE_KEY='cateriumClientsV1772'; + const LEGACY_LOYALTY_KEY='sunClientLoyaltyV1'; + const LEGACY_COMM_KEY='sunClientCommunicationV1'; + const listeners=new Map(); + const clone=value=>{try{return structuredClone(value)}catch(_){return JSON.parse(JSON.stringify(value))}}; + const safeJson=(raw,fallback)=>{try{const value=JSON.parse(raw);return value==null?fallback:value}catch(_){return fallback}}; + const emit=(topic,payload)=>{for(const fn of listeners.get(topic)||[]){try{fn(payload)}catch(error){console.error('[Caterium Data]',error)}}}; + const subscribe=(topic,fn)=>{if(typeof fn!=='function')return()=>{};if(!listeners.has(topic))listeners.set(topic,new Set());listeners.get(topic).add(fn);return()=>listeners.get(topic)?.delete(fn)}; + + function runtimeOrders(){try{if(typeof orders!=='undefined'&&Array.isArray(orders))return orders}catch(_){}return null} + function runtimeCatalog(){try{if(typeof boxes!=='undefined'&&Array.isArray(boxes))return boxes}catch(_){}return null} + function runtimePersist(){try{return typeof persist==='function'?persist:null}catch(_){return null}} + function cloud(){return window.SunCloudV2||null} + function session(){try{return cloud()?.getSession?.()||null}catch(_){return null}} + function workspace(){try{return cloud()?.getWorkspace?.()||null}catch(_){return null}} + function supportMode(){try{return cloud()?.getSupportMode?.()||null}catch(_){return null}} + function isSignedIn(){return Boolean(session()?.user)} + function isSupportReadOnly(){return Boolean(supportMode()||cloud()?.isSupportMode?.())} + function canWrite(permission='app.write'){ + if(isSupportReadOnly())return false; + if(!isSignedIn())return true; + try{const c=cloud();if(typeof c?.hasPermission==='function')return c.hasPermission(permission)||c.hasPermission('app.write')}catch(_){} + return true; + } + + const storage={ + read(key,fallback=null){const raw=localStorage.getItem(String(key));if(raw==null)return fallback;return safeJson(raw,fallback)}, + write(key,value,{silent=false}={}){localStorage.setItem(String(key),JSON.stringify(value));if(!silent)emit(`storage:${key}`,clone(value));return value}, + remove(key){localStorage.removeItem(String(key));emit(`storage:${key}`,null)}, + raw(key){return localStorage.getItem(String(key))} + }; + + function persistDomain(key,list,persistLocal){ + if(!persistLocal){storage.write(key,list,{silent:true});return} + const p=runtimePersist(); + if(p){try{p();return}catch(error){console.warn('[Caterium Data] persist fallback',error)}} + storage.write(key,list,{silent:true}); + } + + function getOrders(){const live=runtimeOrders();return clone(live||storage.read('sunOrders',[])||[])} + function replaceOrders(next,{persistLocal=true,reason='replace'}={}){ + const list=Array.isArray(next)?clone(next):[],live=runtimeOrders(); + if(live){live.length=0;live.push(...clone(list))} + persistDomain('sunOrders',list,persistLocal);emit('orders',{reason,orders:clone(list)}); + if(persistLocal)queueMicrotask(()=>window.CateriumDataV1772?.clients?.adoptLegacy?.({pushServer:true,reason:`orders:${reason}`})); + return list; + } + function getOrder(id){return getOrders().find(o=>String(o?.id)===String(id))||null} + function updateOrder(id,updater,{persistLocal=true,reason='update'}={}){ + const list=getOrders(),index=list.findIndex(o=>String(o?.id)===String(id));if(index<0)return null; + const current=clone(list[index]),next=typeof updater==='function'?updater(current):{...current,...clone(updater||{})};if(!next)return null; + list[index]=next;replaceOrders(list,{persistLocal,reason});return clone(next); + } + function applyServerOrders(changed,{reason='server'}={}){ + const patches=Array.isArray(changed)?changed:[];if(!patches.length)return {changed:0,ids:[]}; + const list=getOrders(),map=new Map(list.map((o,i)=>[String(o?.id),i])),ids=[]; + for(const patch of patches){const id=String(patch?.id??'');if(!id)continue;const index=map.get(id);if(index==null){list.push(clone(patch));map.set(id,list.length-1)}else list[index]=clone(patch);ids.push(id)} + replaceOrders(list,{persistLocal:false,reason}); + try{window.render?.();window.renderOrders?.();window.renderStats?.();window.renderClients?.()}catch(_){} + try{window.dispatchEvent(new CustomEvent('caterium:data-orders-applied',{detail:{ids,reason}}))}catch(_){} + return {changed:ids.length,ids}; + } + + function getCatalog(){const live=runtimeCatalog();return clone(live||storage.read('sunBoxes',[])||[])} + function replaceCatalog(next,{persistLocal=true,reason='replace'}={}){ + const list=Array.isArray(next)?clone(next):[],live=runtimeCatalog(); + if(live){live.length=0;live.push(...clone(list))} + persistDomain('sunBoxes',list,persistLocal);emit('catalog',{reason,items:clone(list)});return list; + } + function getCatalogItem(id){return getCatalog().find(x=>String(x?.id)===String(id))||null} + function upsertCatalogItem(item,{persistLocal=true,reason='upsert'}={}){ + if(!item?.id)return null;const list=getCatalog(),index=list.findIndex(x=>String(x?.id)===String(item.id)),next=clone(item); + if(index<0)list.push(next);else list[index]=next;replaceCatalog(list,{persistLocal,reason});return clone(next); + } + function removeCatalogItem(id,{persistLocal=true,reason='remove'}={}){ + const list=getCatalog(),next=list.filter(x=>String(x?.id)!==String(id));if(next.length===list.length)return false; + replaceCatalog(next,{persistLocal,reason});return true; + } + + const normalizePhone=value=>String(value||'').replace(/\D/g,''); + const normalizeName=value=>String(value||'').trim().replace(/\s+/g,' ').toLowerCase(); + const clientKeyFromValues=(phone,name,orderId='')=>{const p=normalizePhone(phone);if(p)return `p:${p}`;const n=normalizeName(name);if(n)return `n:${n}`;return orderId!==''&&orderId!=null?`o:${String(orderId)}`:''}; + const clientKeyFromOrder=order=>clientKeyFromValues(order?.phone,order?.contact,order?.id); + const orderStamp=order=>`${String(order?.date||'').padStart(10,'0')}T${String(order?.time||'').padStart(5,'0')}`; + function orderTotal(order){ + const explicit=Number(order?.total);if(Number.isFinite(explicit))return explicit; + const catalog=getCatalog();return (Array.isArray(order?.lines)?order.lines:[]).reduce((sum,line)=>{const item=catalog.find(x=>String(x?.id)===String(line?.id));return sum+Number(item?.price||0)*Number(line?.qty||0)},0); + } + function readObject(key){const value=storage.read(key,{});return value&&typeof value==='object'&&!Array.isArray(value)?value:{}} + function cacheCore(profile){return {key:profile.key,name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||''),loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,serverVersion:Number(profile.serverVersion||0)||null,serverUpdatedAt:String(profile.serverUpdatedAt||'')}} + function readClientCache(){return readObject(CLIENT_CACHE_KEY)} + function writeClientCache(map){localStorage.setItem(CLIENT_CACHE_KEY,JSON.stringify(map||{}));return map} + function buildClients(){ + const cached=readClientCache(),loyalty=readObject(LEGACY_LOYALTY_KEY),communication=readObject(LEGACY_COMM_KEY),people=new Map(); + const ensure=key=>{if(!people.has(key)){const old=cached[key]||{};people.set(key,{key,name:String(old.name||''),phone:String(old.phone||''),latestAddress:String(old.latestAddress||''),addresses:new Set(),orderIds:[],orderCount:0,totalSpent:0,latestStamp:'',loyalty:null,communication:null,serverVersion:Number(old.serverVersion||0)||null,serverUpdatedAt:String(old.serverUpdatedAt||'')})}return people.get(key)}; + for(const order of getOrders()){ + const key=clientKeyFromOrder(order);if(!key)continue;const person=ensure(key),stamp=orderStamp(order),address=String(order?.address||'').trim(); + person.orderIds.push(String(order?.id??''));person.orderCount++;if(String(order?.status||'')!=='Отменён')person.totalSpent+=Math.max(0,Number(orderTotal(order)||0));if(address)person.addresses.add(address); + if(!person.latestStamp||stamp>=person.latestStamp){person.latestStamp=stamp;person.name=String(order?.contact||person.name||'');person.phone=String(order?.phone||person.phone||'');person.latestAddress=address||person.latestAddress;} + } + const allKeys=new Set([...people.keys(),...Object.keys(cached),...Object.keys(loyalty),...Object.keys(communication)]); + for(const key of allKeys){const person=ensure(key);person.loyalty=Object.prototype.hasOwnProperty.call(loyalty,key)?clone(loyalty[key]):null;person.communication=Object.prototype.hasOwnProperty.call(communication,key)?clone(communication[key]):null;if(person.latestAddress)person.addresses.add(person.latestAddress)} + return [...people.values()].map(person=>({...person,addresses:[...person.addresses],orderIds:person.orderIds.filter(Boolean),totalSpent:Math.round(person.totalSpent*100)/100,latestStamp:undefined})).sort((a,b)=>(b.orderCount-a.orderCount)||String(a.name||a.key).localeCompare(String(b.name||b.key),'ru')); + } + function listClients(){return clone(buildClients())} + function getClient(key){return listClients().find(x=>x.key===String(key))||null} + function writeLegacyMap(storageKey,key,value){ + const map=readObject(storageKey);if(value==null)delete map[key];else map[key]=clone(value); + bridgeSuppressed=true;try{localStorage.setItem(storageKey,JSON.stringify(map))}finally{bridgeSuppressed=false} + return map; + } + function persistClientCacheFromList(list){const map={};for(const item of list||[])map[item.key]=cacheCore(item);writeClientCache(map);return map} + function changedClientKeys(before,next){const keys=new Set([...Object.keys(before||{}),...Object.keys(next||{})]),changed=[];for(const key of keys){if(JSON.stringify(before?.[key]||null)!==JSON.stringify(next?.[key]||null))changed.push(key)}return changed} + function adoptLegacy({pushServer=false,reason='legacy-adopt'}={}){ + const before=readClientCache(),list=buildClients(),after={};for(const item of list)after[item.key]=cacheCore(item);const changed=changedClientKeys(before,after);writeClientCache(after);emit('clients',{reason,changedKeys:changed,clients:clone(list)});if(pushServer&&changed.length)scheduleServerPush(changed);return {changed:changed.length,keys:changed,clients:clone(list)}; + } + function upsertClient(profile,{pushServer=true,reason='client.upsert'}={}){ + const key=String(profile?.key||clientKeyFromValues(profile?.phone,profile?.name,profile?.orderId)||'');if(!key)return null; + const current=getClient(key)||{key,name:'',phone:'',latestAddress:'',loyalty:null,communication:null};const next={...current,...clone(profile),key}; + if(Object.prototype.hasOwnProperty.call(profile||{},'loyalty'))writeLegacyMap(LEGACY_LOYALTY_KEY,key,next.loyalty); + if(Object.prototype.hasOwnProperty.call(profile||{},'communication'))writeLegacyMap(LEGACY_COMM_KEY,key,next.communication); + const cache=readClientCache();cache[key]=cacheCore(next);writeClientCache(cache);emit('clients',{reason,changedKeys:[key],clients:listClients()});if(pushServer)scheduleServerPush([key]);return clone(getClient(key)||next); + } + function setClientLoyalty(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_LOYALTY_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.loyalty'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.loyalty||null)} + function setClientCommunication(key,value,{pushServer=true}={}){writeLegacyMap(LEGACY_COMM_KEY,String(key),value);adoptLegacy({pushServer:false,reason:'client.communication'});if(pushServer)scheduleServerPush([String(key)]);return clone(getClient(key)?.communication||null)} + function serverPayload(profile){return {schemaVersion:1,key:profile.key,identity:{name:String(profile.name||''),phone:String(profile.phone||''),latestAddress:String(profile.latestAddress||'')},loyalty:profile.loyalty?clone(profile.loyalty):null,communication:profile.communication?clone(profile.communication):null,source:'data-layer-v1772',updatedAt:new Date().toISOString()}} + async function pushClient(key){ + const profile=getClient(key);if(!profile)return {status:'missing',key};if(!isSignedIn())return {status:'local',key};if(isSupportReadOnly()||!canWrite('clients.edit'))return {status:'read-only',key}; + const c=cloud()?.getClient?.(),ws=workspace();if(!c?.rpc||!ws?.id)return {status:'offline',key}; + const cached=readClientCache()[key]||{},expected=Number(cached.serverVersion||0)||null; + const {data,error}=await c.rpc('sun_v17_save_client_v1772',{p_workspace:ws.id,p_client_key:key,p_profile:serverPayload(profile),p_expected_version:expected,p_client_id:cloud()?.getClientId?.()||'browser'});if(error)throw error; + const row=Array.isArray(data)?data[0]:data;if(row){const map=readClientCache();map[key]={...(map[key]||cacheCore(profile)),serverVersion:Number(row.version||0)||null,serverUpdatedAt:String(row.updated_at||'')};writeClientCache(map)} + return {status:'saved',key,version:Number(row?.version||0)||null}; + } + const pendingServerKeys=new Set();let serverPushTimer=0; + function scheduleServerPush(keys){for(const key of keys||[])if(key)pendingServerKeys.add(String(key));if(serverPushTimer)return;serverPushTimer=setTimeout(async()=>{serverPushTimer=0;const keys=[...pendingServerKeys];pendingServerKeys.clear();for(const key of keys){try{await pushClient(key)}catch(error){console.warn('[Caterium Clients] server save failed',key,error?.message||error)}}},250)} + async function pushAllClients(){const result=[];for(const profile of listClients()){try{result.push(await pushClient(profile.key))}catch(error){result.push({status:'error',key:profile.key,error:String(error?.message||error)})}}return result} + + let bridgeSuppressed=false; + function installLegacyClientBridge(){ + if(window.__cateriumClientStorageBridgeV1772||typeof Storage==='undefined')return;window.__cateriumClientStorageBridgeV1772=true; + const proto=Storage.prototype,rawSet=proto.setItem,rawRemove=proto.removeItem; + proto.setItem=function(key,value){const result=rawSet.call(this,key,value);if(!bridgeSuppressed&&this===localStorage&&(key===LEGACY_LOYALTY_KEY||key===LEGACY_COMM_KEY))queueMicrotask(()=>window.CateriumDataV1772?.clients?.adoptLegacy?.({pushServer:true,reason:`legacy-write:${key}`}));return result}; + proto.removeItem=function(key){const result=rawRemove.call(this,key);if(!bridgeSuppressed&&this===localStorage&&(key===LEGACY_LOYALTY_KEY||key===LEGACY_COMM_KEY))queueMicrotask(()=>window.CateriumDataV1772?.clients?.adoptLegacy?.({pushServer:true,reason:`legacy-remove:${key}`}));return result}; + } + + const ordersRepo={list:getOrders,get:getOrder,replace:replaceOrders,update:updateOrder,applyServerChanges:applyServerOrders}; + const catalogRepo={list:getCatalog,get:getCatalogItem,replace:replaceCatalog,upsert:upsertCatalogItem,remove:removeCatalogItem}; + const clientsRepo={list:listClients,get:getClient,upsert:upsertClient,setLoyalty:setClientLoyalty,setCommunication:setClientCommunication,adoptLegacy,push:pushClient,pushAll:pushAllClients,keyFromOrder:clientKeyFromOrder,keyFromValues:clientKeyFromValues,normalizePhone,normalizeName}; + const diagnostics={ + snapshot(){return {version:VERSION,workspaceId:workspace()?.id||null,signedIn:isSignedIn(),supportReadOnly:isSupportReadOnly(),orderCount:getOrders().length,catalogCount:getCatalog().length,clientCount:listClients().length,listenerTopics:listeners.size,pendingClientPushes:pendingServerKeys.size}}, + measure(name,fn){const started=performance.now();return Promise.resolve().then(fn).finally(()=>emit('metric',{name,durationMs:Math.round((performance.now()-started)*10)/10,at:new Date().toISOString()}))} + }; + + const api={VERSION,RELEASE,storage,orders:ordersRepo,catalog:catalogRepo,clients:clientsRepo,subscribe,emit,cloud,session,workspace,isSignedIn,isSupportReadOnly,canWrite,diagnostics}; + window.CateriumDataV1772=api; + window.CateriumDataV1771=api; + window.CateriumDataV1770=api; + window.CateriumData=api; + installLegacyClientBridge(); + adoptLegacy({pushServer:false,reason:'boot'}); + window.addEventListener('sun:cloud-permissions-changed',()=>setTimeout(()=>clientsRepo.pushAll().catch(()=>{}),800)); + window.addEventListener('sun:cloud-sync-complete',()=>setTimeout(()=>clientsRepo.adoptLegacy({pushServer:true,reason:'cloud-sync'}),300)); + setTimeout(()=>clientsRepo.pushAll().catch(()=>{}),3500); + try{window.dispatchEvent(new CustomEvent('caterium:data-ready',{detail:{version:VERSION,phase:3,clients:true}}))}catch(_){} +})();