diff --git a/docs/release-manifest.json b/docs/release-manifest.json index b06661c..97170ea 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -1,6 +1,6 @@ { "app": "Caterium", - "version": "v17.7.1", + "version": "v17.7.2", "channel": "production", "schema": 17, "legacyStateKept": true, @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v76-20260909-v17-7-1-data-layer-adoption", + "pwaCache": "v77-20260909-v17-7-2-clients-foundation", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, @@ -108,7 +108,7 @@ "catalogCompositionTildaEndpoint": "getproduct", "catalogCompositionPremiumForceRefresh": true, "catalogCompositionCacheRequiresPremium": true, - "notes": "Data layer adoption: base order save/status/delete and catalog save/delete paths now write through Caterium Data Layer with legacy local persistence retained as a fallback.", + "notes": "Client foundation: canonical client identity, loyalty and communication data routed through Caterium Data Layer with legacy localStorage compatibility and additive normalized server writes.", "catalogPhotoSources": 113, "catalogPhotosStoredInCatalog": true, "catalogLegacyPhotosInCatalog": 60, @@ -246,7 +246,7 @@ "signupTrial": "14-day Full", "developerMfaInputSelectorFixed": true, "developerMfaRepeatedValidationToastsFixed": true, - "release": "20260909-v17-7-1-data-layer-adoption", + "release": "20260909-v17-7-2-clients-foundation", "registrationFlow": "email-password-confirm-company-auto-login", "emailConfirmationRequired": false, "employeeInviteLinks": false, @@ -327,7 +327,7 @@ "cloudConflictMaxRetries": 4, "errorLogDedupMinutes": 5, "networkFailureBackoff": true, - "stabilityLoggerVersion": "17.7.1", + "stabilityLoggerVersion": "17.7.2", "developerAdminCheckTtlMs": 30000, "supportRefreshLightweight": true, "supportRefreshIntervalMs": 20000, @@ -368,7 +368,12 @@ "errorTelemetryRejectsLocalFile": true, "errorTelemetryServerDedupMinutes": 5, "errorTelemetryRetentionDays": 30, - "dataLayerPhase": 2, + "dataLayerPhase": 3, "dataLayerLegacyOrderWrites": true, - "dataLayerLegacyCatalogWrites": true + "dataLayerLegacyCatalogWrites": true, + "clientDataLayer": true, + "clientLegacyDoubleWrite": true, + "clientCanonicalIdentity": "phone > normalized name > order id fallback", + "clientNormalizedRpc": "sun_v17_save_client_v1772", + "clientLegacyRowsPreserved": true } diff --git a/docs/releases/V17.7.2-CHANGES.txt b/docs/releases/V17.7.2-CHANGES.txt new file mode 100644 index 0000000..654d568 --- /dev/null +++ b/docs/releases/V17.7.2-CHANGES.txt @@ -0,0 +1,8 @@ +Caterium v17.7.2 — Clients Foundation + +- Added a client repository to Caterium Data Layer. +- Canonical client identity: phone digits, then normalized name, then order ID only as a fallback. +- Loyalty and communication stores remain backward-compatible in localStorage while being adopted by the client repository. +- Added additive normalized client RPC sun_v17_save_client_v1772 with workspace, subscription, feature and permission checks plus optimistic version protection. +- Existing legacy client rows are preserved for rollback safety. +- No client UI redesign in this release. diff --git a/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql b/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql new file mode 100644 index 0000000..da71878 --- /dev/null +++ b/ops/sql/SUPABASE-V17.7.2-CLIENTS-FOUNDATION.sql @@ -0,0 +1,74 @@ +-- Caterium v17.7.2 — normalized client foundation +-- Additive migration: preserves legacy state and existing client rows. + +create or replace function public.sun_v17_save_client_v1772( + p_workspace uuid, + p_client_key text, + p_profile jsonb, + p_expected_version bigint default null, + p_client_id text default null +) +returns table(client_key text, version bigint, data jsonb, updated_at timestamptz) +language plpgsql +security definer +set search_path = public +as $function$ +declare + cur bigint; + saved public.sun_v17_clients%rowtype; + next_name text; + next_phone text; + next_address text; +begin + if public.sun_member_role(p_workspace) is null then + raise exception 'Access denied'; + end if; + if public.sun_subscription_access_mode(p_workspace) <> 'full' + or not public.sun_workspace_has_feature(p_workspace,'clients') then + raise exception 'Клиенты недоступны для изменения'; + end if; + if not public.sun_has_permission(p_workspace,'clients.edit') then + raise exception 'Нет права изменять клиентов'; + end if; + if coalesce(nullif(trim(p_client_key),''),'') = '' then + raise exception 'Client key is required'; + end if; + if jsonb_typeof(coalesce(p_profile,'{}'::jsonb)) <> 'object' then + raise exception 'Invalid client profile'; + end if; + + select c.version into cur + from public.sun_v17_clients c + where c.workspace_id=p_workspace and c.client_key=p_client_key + for update; + + if found and p_expected_version is not null and cur <> p_expected_version then + raise exception using errcode='40001',message=format('SUN_CLIENT_CONFLICT expected=%s actual=%s',p_expected_version,cur); + end if; + + next_name=nullif(trim(coalesce(p_profile#>>'{identity,name}',p_profile->>'name','')),''); + next_phone=nullif(trim(coalesce(p_profile#>>'{identity,phone}',p_profile->>'phone','')),''); + next_address=nullif(trim(coalesce(p_profile#>>'{identity,latestAddress}',p_profile->>'latestAddress','')),''); + + insert into public.sun_v17_clients(workspace_id,client_key,name,phone,latest_address,data,version,created_at,updated_at) + values(p_workspace,p_client_key,next_name,next_phone,next_address,p_profile,1,now(),now()) + on conflict(workspace_id,client_key) do update set + name=coalesce(excluded.name,sun_v17_clients.name), + phone=coalesce(excluded.phone,sun_v17_clients.phone), + latest_address=coalesce(excluded.latest_address,sun_v17_clients.latest_address), + data=excluded.data, + version=case when sun_v17_clients.data is distinct from excluded.data then sun_v17_clients.version+1 else sun_v17_clients.version end, + updated_at=case when sun_v17_clients.data is distinct from excluded.data then now() else sun_v17_clients.updated_at end + returning * into saved; + + if not found or cur is distinct from saved.version then + insert into public.sun_v17_change_events(workspace_id,entity,entity_key,operation,version,client_id,created_by) + values(p_workspace,'client',p_client_key,'upsert',saved.version,p_client_id,auth.uid()); + end if; + + return query select saved.client_key,saved.version,saved.data,saved.updated_at; +end; +$function$; + +revoke execute on function public.sun_v17_save_client_v1772(uuid,text,jsonb,bigint,text) from public, anon; +grant execute on function public.sun_v17_save_client_v1772(uuid,text,jsonb,bigint,text) to authenticated, service_role; diff --git a/package-lock.json b/package-lock.json index 31c056e..9b471ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "caterium-app", - "version": "17.7.1", + "version": "17.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "caterium-app", - "version": "17.7.1", + "version": "17.7.2", "devDependencies": { "@playwright/test": "^1.51.0", "http-server": "^14.1.1", diff --git a/package.json b/package.json index 6e45c33..1d87a2e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "caterium-app", "private": true, - "version": "17.7.1", + "version": "17.7.2", "type": "module", "scripts": { - "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/data-layer-v1771.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js", + "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/performance.js && node --check public/core/data-layer-v1772.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js", "test:static": "node tests/static-security.mjs", "check:release": "node tests/release-check.mjs", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release", diff --git a/public/app-runtime.js b/public/app-runtime.js index 91dbdb5..321fbf8 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -4221,8 +4221,8 @@ window.SUN_LEGACY_CATALOG_V175=[{"id":"1","name":"Фуршетный бокс 'use strict'; if (window.SunStabilityV17) return; - const VERSION = '17.7.1'; - const RELEASE = 'v17.7.1 Data Layer Adoption'; + const VERSION = '17.7.2'; + const RELEASE = 'v17.7.2 Clients Foundation'; const DB_NAME = 'SunStabilityV17'; const DB_VERSION = 1; const DAILY_KEY = 'sunV17DailyBackup'; 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(_){} +})(); diff --git a/public/core/performance.js b/public/core/performance.js index 43c34e5..8dc6592 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; - const VERSION='17.7.1'; - const RELEASE='20260909-v17-7-1-data-layer-adoption'; + const VERSION='17.7.2'; + const RELEASE='20260909-v17-7-2-clients-foundation'; const critical=img=>img.closest('header,.brand,#sunCloudAuthGate,.sun-auth-gate')||img.id==='sunLoginLogo'||img.classList.contains('sun-live-catalog-logo'); const tune=img=>{ if(!(img instanceof HTMLImageElement)||critical(img))return; @@ -92,8 +92,8 @@ function startMemoryTimer(){if(memoryTimer)return;memoryTimer=setInterval(()=>{if(!document.hidden&&developerVisible())enhanceDeveloperMemory({force:true}).catch(()=>{})},MEMORY_REFRESH_MS)} function loadDataLayer(){ - if(window.CateriumDataV1771||document.getElementById('cateriumDataV1771Script'))return; - const script=document.createElement('script');script.id='cateriumDataV1771Script';script.src=`core/data-layer-v1771.js?v=${RELEASE}`;script.async=false;script.onerror=()=>console.error('[Caterium] Не загрузился data-layer-v1771.js');document.head.appendChild(script); + if(window.CateriumDataV1772||document.getElementById('cateriumDataV1772Script'))return; + const script=document.createElement('script');script.id='cateriumDataV1772Script';script.src=`core/data-layer-v1772.js?v=${RELEASE}`;script.async=false;script.onerror=()=>console.error('[Caterium] Не загрузился data-layer-v1772.js');document.head.appendChild(script); } function loadServerAutomation(){ if(window.CateriumServerAutomationV1770||document.getElementById('cateriumServerAutomationV1770Script'))return; diff --git a/public/index.html b/public/index.html index 5840f4c..185be2a 100644 --- a/public/index.html +++ b/public/index.html @@ -71,7 +71,7 @@ button{touch-action:manipulation} #sunGlobalSearchBtn kbd{display:none!important} #sunSyncSettingsCard>div[style*="grid-template-columns"]{grid-template-columns:1fr!important} -}
Стоимость позиций0 ₽
Предоплата0 ₽
К оплате0 ₽
Нажмите «Изменить», чтобы открыть заказ. Сумма, предоплата и остаток сохраняются вместе с заказом.
В этой версии каталог продуктов пополняется из составов боксов. Остатки и цены добавим следующим шагом.
Выберите боксы слева.
';updateOrderSummary()}function add(id){let l=draft.lines.find(x=>x.id==id);l?l.qty++:draft.lines.push({id,qty:1});render()}function qty(id,n){if(n<1)draft.lines=draft.lines.filter(x=>x.id!=id);else draft.lines.find(x=>x.id==id).qty=n;render()}function details(on){orderForm.style.display=on?'none':'block';const detailsPanel=document.getElementById('details');detailsPanel.style.display=on?'block':'none';orderTab.classList.toggle('on',!on);detailTab.classList.toggle('on',on)}function saveOrder(){if(!draft.lines.length)return alert('Добавьте хотя бы один бокс.');draft.event=document.getElementById('event').value;draft.date=date.value;draft.time=time.value;draft.contact=contact.value;draft.phone=phone.value;draft.address=address.value;draft.note=note.value;draft.total=calcOrderTotal();draft.prepayment=Math.max(0,Number(prepayment.value||0));draft.balance=Math.max(0,draft.total-draft.prepayment);if(!draft.status)draft.status='Новый';if(!draft.id)draft.id=Math.max(0,...orders.map(x=>x.id))+1;let n=orders.findIndex(x=>x.id==draft.id);n<0?orders.push(structuredClone(draft)):orders[n]=structuredClone(draft);persistOrders('order.save');deleteOrderBtn.style.display='inline-block';renderOrders();updateOrderSummary();alert('Заказ сохранён.');}function orderTotalValue(o){if(Number.isFinite(Number(o.total)))return Number(o.total);return (o.lines||[]).reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+Number(b?.price||0)*Number(l.qty||0)},0)}function renderOrders(){ordersList.innerHTML=orders.length?orders.map(o=>`Сохранённых заказов пока нет.
'}function status(id,s){orders.find(x=>x.id==id).status=s;persistOrders('order.status')}function openOrder(id){draft=structuredClone(orders.find(x=>x.id==id));document.getElementById('event').value=draft.event;date.value=draft.date;time.value=draft.time;contact.value=draft.contact||'';phone.value=draft.phone||'';address.value=draft.address||'';note.value=draft.note||'';prepayment.value=Number(draft.prepayment||0);deleteOrderBtn.style.display='inline-block';show('new',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Новый заказ')||document.querySelector('nav button'));render()}function resetDraft(){draft={id:0,lines:[]};document.getElementById('event').value='';date.value=new Date().toISOString().slice(0,10);time.value='12:00';contact.value='';phone.value='';address.value='';note.value='';prepayment.value=0;deleteOrderBtn.style.display='none';render()}function deleteOrderById(id){let o=orders.find(x=>x.id==id);if(!o||!confirm(`Удалить заказ №${id} «${o.event}»?`))return;orders=orders.filter(x=>x.id!=id);persistOrders('order.delete');renderOrders();if(draft.id==id)resetDraft()}function deleteOrder(){if(!draft.id)return;deleteOrderById(draft.id);show('orders',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Заказы')||document.querySelectorAll('nav button')[1])}function renderStats(){statsList.innerHTML=boxes.map(b=>`${sunEsc(b.name)} — ${orders.filter(o=>o.status!='Отменён').reduce((n,o)=>n+(o.lines.find(l=>l.id==b.id)?.qty||0),0)} заказано
`).join('')}function modal(x,on=true){document.getElementById(x).classList.toggle('on',on)}function closeModal(x){modal(x,false)}function openManager(){modal('manager');managerList.innerHTML=boxes.map(b=>``).join('')}function editBox(id){edited=id?structuredClone(boxes.find(x=>x.id==id)):{id:'',name:'Новый бокс',price:0,ingredients:[]};editTitle.textContent=edited.id?'Изменить бокс':'Новый бокс';boxName.value=edited.name;boxPrice.value=Number(edited.price||0);editPhoto.style.display=edited.photo?'block':'none';editPhoto.src=sunImg(edited.photo)||'';deleteBox.style.display=edited.id?'block':'none';modal('editor');renderIngredients()}function readPhoto(x){let r=new FileReader();r.onload=()=>{edited.photo=sunImg(r.result);editPhoto.src=edited.photo||'';editPhoto.style.display='block'};r.readAsDataURL(x.files[0])}function renderIngredients(){ingredients.innerHTML=edited.ingredients.map((x,i)=>``).join('')}function addIngredient(){edited.ingredients.push(['Новый продукт',1,'шт.']);renderIngredients()}function saveBox(){edited.name=boxName.value.trim();edited.price=Math.max(0,Number(boxPrice.value||0));if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=sunUUID();boxes.push(edited)}else boxes[boxes.findIndex(x=>x.id==edited.id)]=edited;persistCatalog('catalog.save');closeModal('editor');closeModal('manager');render();renderOrders();window.sunClientOfferCatalogChanged?.(edited.id)}function removeBox(){if(confirm('Удалить бокс?')){boxes=boxes.filter(x=>x.id!=edited.id);persistCatalog('catalog.delete');closeModal('editor');closeModal('manager');render()}}function openPreview(){let m={};draft.lines.forEach(l=>boxes.find(b=>b.id==l.id).ingredients.forEach(x=>{let k=x[0]+'|'+x[2];m[k]=(m[k]||0)+x[1]*l.qty}));prepTitle.textContent='Заготовки для: '+document.getElementById('event').value;prep.innerHTML=Object.entries(m).map(([k,v])=>{let [p,u]=k.split('|');return `${sunEsc(p)}${sunEsc(v)} ${sunEsc(u)}
`}).join('');modal('preview')}render();updateOrderSummary(); \ No newline at end of file +const sunEsc=window.SunSafe.escapeHTML,sunAttr=window.SunSafe.escapeAttr,sunId=window.SunSafe.idToken,sunImg=window.SunSafe.safeImageSrc,sunInt=v=>Number.isFinite(Number(v))?Math.trunc(Number(v)):0;let boxes=JSON.parse(localStorage.sunBoxes||'null')||[{id:'1',name:'Фуршетный бокс №1 — канапе мясные',ingredients:[['Хлеб',25,'шт.'],['Салями модеро',10,'кус.'],['Мортаделла',10,'кус.'],['Шейка коппа',10,'кус.'],['Ветчина пармская',10,'кус.'],['Салями в сыре',10,'кус.'],['Фрилис',.33,'пачки'],['Томаты черри жёлтые',2.5,'шт.'],['Томаты черри красные',2.5,'шт.'],['Огурец консервированный',5,'кус.'],['Маслины',2.5,'шт.'],['Слайсы огурца свежего',5,'шт.'],['Фисташка дроблёная',1,'г']]},{id:'2',name:'Фуршетный бокс №2 — канапе рыбные',ingredients:[['Хлеб',15,'шт.'],['Креветка',10,'шт.'],['Тарталетка',10,'шт.'],['Икра красная',20,'г'],['Крем-чиз',30,'г'],['Лосось кубиком',35,'г']]},{id:'3',name:'Фуршетный бокс №3 — микс тарталеток',ingredients:[['Тарталетка',25,'шт.'],['Креветка',5,'шт.'],['Икра щучья',25,'г'],['Икра красная',25,'г'],['Мортаделла',5,'кус.'],['Груша',5,'кус.'],['Крем-чиз',25,'г']]},{id:'4',name:'Фуршетный бокс №4 — премиум морепродукты',ingredients:[['Тарталетка',10,'шт.'],['Ролл из зелёного блинчика',5,'шт.'],['Ролл из чёрного блинчика',5,'шт.'],['Тунец обожжённый',5,'шт.'],['Креветка',5,'шт.'],['Слайс лосося',10,'шт.'],['Икра красная',25,'г']]},{id:'5',name:'Фуршетный бокс №5 — брускетты мясные',ingredients:[['Хлеб',25,'шт.'],['Салями в сыре',10,'шт.'],['Ростбиф',10,'шт.'],['Шейка коппа',10,'шт.'],['Прошутто',10,'шт.'],['Салями модеро',10,'шт.']]},{id:'6',name:'Фуршетный бокс №6 — сыры в шотах',ingredients:[['Моцарелла',15,'шариков'],['Дорблю',15,'кубиков'],['Камамбер',10,'кус.'],['Пармезан',15,'кус.'],['Чеддер',15,'кус.'],['Груша',5,'кус.'],['Виноград',5,'шт.']]}],orders=JSON.parse(localStorage.sunOrders||'[]'),draft={id:0,lines:[]},edited=null;date.value=new Date().toISOString().slice(0,10);for(let i=0;i<48;i++){let t=String(i>>1).padStart(2,'0')+':'+(i%2?'30':'00');time.add(new Option(t,t));}time.value='12:00';function persist(){localStorage.sunBoxes=JSON.stringify(boxes);localStorage.sunOrders=JSON.stringify(orders)}function dataLayer(){return window.CateriumDataV1772||window.CateriumDataV1771||window.CateriumData||window.CateriumDataV1770||null}function persistOrders(reason){let d=dataLayer();if(d?.orders?.replace){d.orders.replace(orders,{persistLocal:true,reason:reason||'legacy.orders'});return}persist()}function persistCatalog(reason){let d=dataLayer();if(d?.catalog?.replace){d.catalog.replace(boxes,{persistLocal:true,reason:reason||'legacy.catalog'});return}persist()}function show(id,b){document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));document.getElementById(id).classList.add('on');document.querySelectorAll('nav button').forEach(x=>x.classList.remove('on'));b.classList.add('on');renderOrders();renderStats()}function money(n){return Number(n||0).toLocaleString('ru-RU')+' ₽'}function calcOrderTotal(){return draft.lines.reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+(Number(b?.price||0)*Number(l.qty||0))},0)}function updateOrderSummary(){let total=calcOrderTotal(),paid=Math.max(0,Number(prepayment.value||0)),rest=Math.max(0,total-paid);orderTotal.value=total;balance.value=rest;summaryTotal.textContent=money(total);summaryPrepayment.textContent=money(paid);summaryBalance.textContent=money(rest)}function render(){tiles.innerHTML=''+boxes.map(b=>``).join('');lines.innerHTML=draft.lines.length?draft.lines.map(l=>{let b=boxes.find(x=>x.id==l.id);return `Выберите боксы слева.
';updateOrderSummary()}function add(id){let l=draft.lines.find(x=>x.id==id);l?l.qty++:draft.lines.push({id,qty:1});render()}function qty(id,n){if(n<1)draft.lines=draft.lines.filter(x=>x.id!=id);else draft.lines.find(x=>x.id==id).qty=n;render()}function details(on){orderForm.style.display=on?'none':'block';const detailsPanel=document.getElementById('details');detailsPanel.style.display=on?'block':'none';orderTab.classList.toggle('on',!on);detailTab.classList.toggle('on',on)}function saveOrder(){if(!draft.lines.length)return alert('Добавьте хотя бы один бокс.');draft.event=document.getElementById('event').value;draft.date=date.value;draft.time=time.value;draft.contact=contact.value;draft.phone=phone.value;draft.address=address.value;draft.note=note.value;draft.total=calcOrderTotal();draft.prepayment=Math.max(0,Number(prepayment.value||0));draft.balance=Math.max(0,draft.total-draft.prepayment);if(!draft.status)draft.status='Новый';if(!draft.id)draft.id=Math.max(0,...orders.map(x=>x.id))+1;let n=orders.findIndex(x=>x.id==draft.id);n<0?orders.push(structuredClone(draft)):orders[n]=structuredClone(draft);persistOrders('order.save');deleteOrderBtn.style.display='inline-block';renderOrders();updateOrderSummary();alert('Заказ сохранён.');}function orderTotalValue(o){if(Number.isFinite(Number(o.total)))return Number(o.total);return (o.lines||[]).reduce((sum,l)=>{let b=boxes.find(x=>x.id==l.id);return sum+Number(b?.price||0)*Number(l.qty||0)},0)}function renderOrders(){ordersList.innerHTML=orders.length?orders.map(o=>`Сохранённых заказов пока нет.
'}function status(id,s){orders.find(x=>x.id==id).status=s;persistOrders('order.status')}function openOrder(id){draft=structuredClone(orders.find(x=>x.id==id));document.getElementById('event').value=draft.event;date.value=draft.date;time.value=draft.time;contact.value=draft.contact||'';phone.value=draft.phone||'';address.value=draft.address||'';note.value=draft.note||'';prepayment.value=Number(draft.prepayment||0);deleteOrderBtn.style.display='inline-block';show('new',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Новый заказ')||document.querySelector('nav button'));render()}function resetDraft(){draft={id:0,lines:[]};document.getElementById('event').value='';date.value=new Date().toISOString().slice(0,10);time.value='12:00';contact.value='';phone.value='';address.value='';note.value='';prepayment.value=0;deleteOrderBtn.style.display='none';render()}function deleteOrderById(id){let o=orders.find(x=>x.id==id);if(!o||!confirm(`Удалить заказ №${id} «${o.event}»?`))return;orders=orders.filter(x=>x.id!=id);persistOrders('order.delete');renderOrders();if(draft.id==id)resetDraft()}function deleteOrder(){if(!draft.id)return;deleteOrderById(draft.id);show('orders',[...document.querySelectorAll('nav button')].find(x=>x.textContent.trim()==='Заказы')||document.querySelectorAll('nav button')[1])}function renderStats(){statsList.innerHTML=boxes.map(b=>`${sunEsc(b.name)} — ${orders.filter(o=>o.status!='Отменён').reduce((n,o)=>n+(o.lines.find(l=>l.id==b.id)?.qty||0),0)} заказано
`).join('')}function modal(x,on=true){document.getElementById(x).classList.toggle('on',on)}function closeModal(x){modal(x,false)}function openManager(){modal('manager');managerList.innerHTML=boxes.map(b=>``).join('')}function editBox(id){edited=id?structuredClone(boxes.find(x=>x.id==id)):{id:'',name:'Новый бокс',price:0,ingredients:[]};editTitle.textContent=edited.id?'Изменить бокс':'Новый бокс';boxName.value=edited.name;boxPrice.value=Number(edited.price||0);editPhoto.style.display=edited.photo?'block':'none';editPhoto.src=sunImg(edited.photo)||'';deleteBox.style.display=edited.id?'block':'none';modal('editor');renderIngredients()}function readPhoto(x){let r=new FileReader();r.onload=()=>{edited.photo=sunImg(r.result);editPhoto.src=edited.photo||'';editPhoto.style.display='block'};r.readAsDataURL(x.files[0])}function renderIngredients(){ingredients.innerHTML=edited.ingredients.map((x,i)=>``).join('')}function addIngredient(){edited.ingredients.push(['Новый продукт',1,'шт.']);renderIngredients()}function saveBox(){edited.name=boxName.value.trim();edited.price=Math.max(0,Number(boxPrice.value||0));if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=sunUUID();boxes.push(edited)}else boxes[boxes.findIndex(x=>x.id==edited.id)]=edited;persistCatalog('catalog.save');closeModal('editor');closeModal('manager');render();renderOrders();window.sunClientOfferCatalogChanged?.(edited.id)}function removeBox(){if(confirm('Удалить бокс?')){boxes=boxes.filter(x=>x.id!=edited.id);persistCatalog('catalog.delete');closeModal('editor');closeModal('manager');render()}}function openPreview(){let m={};draft.lines.forEach(l=>boxes.find(b=>b.id==l.id).ingredients.forEach(x=>{let k=x[0]+'|'+x[2];m[k]=(m[k]||0)+x[1]*l.qty}));prepTitle.textContent='Заготовки для: '+document.getElementById('event').value;prep.innerHTML=Object.entries(m).map(([k,v])=>{let [p,u]=k.split('|');return `${sunEsc(p)}${sunEsc(v)} ${sunEsc(u)}
`}).join('');modal('preview')}render();updateOrderSummary(); \ No newline at end of file diff --git a/public/service-worker.js b/public/service-worker.js index 62b45ea..44936d0 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,8 +1,8 @@ -const CACHE='sun-catering-pwa-v76-20260909-v17-7-1-data-layer-adoption'; -const VERSION='20260909-v17-7-1-data-layer-adoption'; +const CACHE='sun-catering-pwa-v77-20260909-v17-7-2-clients-foundation'; +const VERSION='20260909-v17-7-2-clients-foundation'; const CORE=[ './','./index.html', - `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/data-layer-v1771.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, + `./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/data-layer-v1772.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`, './offer-gallery/001.jpg','./offer-gallery/002.jpg', './catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg', './sun-logo.png','./caterium-login-logo.png','./pwa-icon-192.png','./pwa-icon-512.png','./manifest.webmanifest', diff --git a/tests/app.spec.mjs b/tests/app.spec.mjs index 61ac733..7a11019 100644 --- a/tests/app.spec.mjs +++ b/tests/app.spec.mjs @@ -156,7 +156,7 @@ test('Developer Console memory refresh is bounded and does not react to its own const result=await page.evaluate(()=>({count:window.__rpcCount,box:document.querySelectorAll('#sunDevMemoryV1761').length,version:document.getElementById('sunDevReleaseVersion')?.textContent})); expect(result.count).toBeLessThanOrEqual(2); expect(result.box).toBe(1); - expect(result.version).toBe('17.7.1'); + expect(result.version).toBe('17.7.2'); }); test('mobile body does not overflow viewport', async ({ page }, testInfo) => { test.skip(testInfo.project.name!=='mobile-390'); await page.goto('/index.html', { waitUntil:'domcontentloaded' }); await page.waitForTimeout(500); @@ -172,7 +172,7 @@ test('v17.6.5 stays free of timer page errors during idle', async ({ page }, tes await page.waitForTimeout(5500); expect(errors).toEqual([]); const runtimeSource=fs.readFileSync(path.join(process.cwd(),'public','app-runtime.js'),'utf8'); - expect(runtimeSource).toContain("const VERSION = '17.7.1'"); + expect(runtimeSource).toContain("const VERSION = '17.7.2'"); }); test('v17.6.5 support refresh uses lightweight cloud API', async ({ page }) => { @@ -261,9 +261,9 @@ test('v17.6.9 offer workspace module boots', async ({ page }) => { test('v17.7.1 data layer can update orders without direct UI globals', async ({ page }) => { await page.goto('/index.html',{waitUntil:'domcontentloaded'}); - await injectCore(page,'data-layer-v1771.js','CateriumDataV1771'); - const result=await page.evaluate(()=>{const d=window.CateriumDataV1771;d.orders.replace([{id:77,status:'Новый',total:1000}],{persistLocal:false,reason:'e2e-seed'});const before=d.orders.get(77);d.orders.update(77,o=>({...o,status:'Тест'}),{persistLocal:false});return {before:before.status,after:d.orders.get(77).status,version:d.VERSION,alias:window.CateriumDataV1770===d}}); - expect(result).toEqual({before:'Новый',after:'Тест',version:'17.7.1',alias:true}); + await injectCore(page,'data-layer-v1772.js','CateriumDataV1772'); + const result=await page.evaluate(()=>{const d=window.CateriumDataV1772;d.orders.replace([{id:77,status:'Новый',total:1000}],{persistLocal:false,reason:'e2e-seed'});const before=d.orders.get(77);d.orders.update(77,o=>({...o,status:'Тест'}),{persistLocal:false});return {before:before.status,after:d.orders.get(77).status,version:d.VERSION,alias:window.CateriumDataV1770===d}}); + expect(result).toEqual({before:'Новый',after:'Тест',version:'17.7.2',alias:true}); }); test('v17.7.0 server automation applies returned normalized orders through data layer', async ({ page }) => { @@ -277,14 +277,37 @@ test('v17.7.0 server automation applies returned normalized orders through data test('v17.7.1 data layer serves orders and catalog with v17.7.0 compatibility alias', async ({ page }) => { await page.goto('/index.html',{waitUntil:'domcontentloaded'}); - await injectCore(page,'data-layer-v1771.js','CateriumDataV1771'); + await injectCore(page,'data-layer-v1772.js','CateriumDataV1772'); const result=await page.evaluate(()=>{ - const d=window.CateriumDataV1771,beforeOrders=d.orders.list(),beforeCatalog=d.catalog.list(); + const d=window.CateriumDataV1772,beforeOrders=d.orders.list(),beforeCatalog=d.catalog.list(); d.orders.replace([...beforeOrders,{id:'qa-v1771',event:'QA',lines:[]}],{persistLocal:false,reason:'qa-order'}); d.catalog.replace([...beforeCatalog,{id:'qa-v1771',name:'QA item',price:0,ingredients:[]}],{persistLocal:false,reason:'qa-catalog'}); const hasOrder=Boolean(d.orders.get('qa-v1771')),hasCatalog=Boolean(d.catalog.get('qa-v1771')); d.orders.replace(beforeOrders,{persistLocal:false,reason:'qa-restore'});d.catalog.replace(beforeCatalog,{persistLocal:false,reason:'qa-restore'}); return {version:d.VERSION,alias:window.CateriumDataV1770===d,hasOrder,hasCatalog}; }); - expect(result).toEqual({version:'17.7.1',alias:true,hasOrder:true,hasCatalog:true}); + expect(result).toEqual({version:'17.7.2',alias:true,hasOrder:true,hasCatalog:true}); +}); + + +test('v17.7.2 client data layer uses phone then normalized name identity', async ({ page }) => { + await page.addInitScript(()=>{ + localStorage.setItem('sunOrders',JSON.stringify([{id:1,contact:' Иван Петров ',phone:'',address:'A',total:1000,lines:[]},{id:2,contact:'иван петров',phone:'',address:'B',total:2000,lines:[]},{id:3,contact:'Анна',phone:'+7 (999) 123-45-67',total:3000,lines:[]}])) + localStorage.setItem('sunClientLoyaltyV1',JSON.stringify({'n:иван петров':{enabled:true,fixedDiscount:5}})); + localStorage.setItem('sunClientCommunicationV1',JSON.stringify({'p:79991234567':{preferred:'telegram'}})); + }); + await page.goto('/index.html',{waitUntil:'domcontentloaded'}); + await injectCore(page,'data-layer-v1772.js','CateriumDataV1772'); + const result=await page.evaluate(()=>{ + const d=window.CateriumDataV1772,clients=d.clients.list(); + return {version:d.VERSION,nameKey:d.clients.keyFromValues('', ' Иван Петров '),phoneKey:d.clients.keyFromValues('+7 (999) 123-45-67','Анна'),ivan:clients.find(x=>x.key==='n:иван петров'),anna:clients.find(x=>x.key==='p:79991234567'),alias:window.CateriumDataV1771===d}; + }); + expect(result.version).toBe('17.7.2'); + expect(result.nameKey).toBe('n:иван петров'); + expect(result.phoneKey).toBe('p:79991234567'); + expect(result.ivan.orderCount).toBe(2); + expect(result.ivan.totalSpent).toBe(3000); + expect(result.ivan.loyalty.fixedDiscount).toBe(5); + expect(result.anna.communication.preferred).toBe('telegram'); + expect(result.alias).toBeTruthy(); }); diff --git a/tests/release-check.mjs b/tests/release-check.mjs index bddf207..61cb3db 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -4,7 +4,7 @@ const root=process.cwd(), pub=path.join(root,'public'); const read=p=>fs.readFileSync(path.join(pub,p),'utf8'); const readRoot=p=>fs.readFileSync(path.join(root,p),'utf8'); let bad=0;const check=(v,m)=>{console.log(`${v?'OK':'FAIL'}: ${m}`);if(!v)bad++}; -const index=read('index.html'),runtime=read('app-runtime.js'),sw=read('service-worker.js'),css=read('core/stability-v1760.css'),performance=read('core/performance.js'),dataLayer=read('core/data-layer-v1771.js'),legacy=read('legacy/bootstrap.js'),ops=read('core/ops-ux-v1762.js'),hotfix=read('core/hotfix-v1763.js'),ux=read('core/ux-fixes-v1764.js'),classic=read('core/classic-offer-pdf-v1767.js'),developerUX=read('core/developer-console-v1768.js'),offerWorkspace=read('core/offer-workspace-v1769.js'); +const index=read('index.html'),runtime=read('app-runtime.js'),sw=read('service-worker.js'),css=read('core/stability-v1760.css'),performance=read('core/performance.js'),dataLayer=read('core/data-layer-v1772.js'),legacy=read('legacy/bootstrap.js'),ops=read('core/ops-ux-v1762.js'),hotfix=read('core/hotfix-v1763.js'),ux=read('core/ux-fixes-v1764.js'),classic=read('core/classic-offer-pdf-v1767.js'),developerUX=read('core/developer-console-v1768.js'),offerWorkspace=read('core/offer-workspace-v1769.js'); const pkg=JSON.parse(readRoot('package.json')); const lock=JSON.parse(readRoot('package-lock.json')); const releaseManifest=JSON.parse(readRoot('docs/release-manifest.json')); @@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent') check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v76-20260909-v17-7-1-data-layer-adoption')&&sw.includes('data-layer-v1771.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.0'); -check(index.includes('20260909-v17-7-1-data-layer-adoption')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.0'); +check(sw.includes('v77-20260909-v17-7-2-clients-foundation')&&sw.includes('data-layer-v1772.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.2'); +check(index.includes('20260909-v17-7-2-clients-foundation')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.2'); check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned'); check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned'); check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded'); @@ -39,17 +39,17 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30 check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match'); check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json'); check(releaseManifest.channel==='production','release manifest channel is production'); -check(String(releaseManifest.pwaCache||'').includes('v76-20260909-v17-7-1-data-layer-adoption'),'release manifest points to current PWA cache'); -check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.1'); +check(String(releaseManifest.pwaCache||'').includes('v77-20260909-v17-7-2-clients-foundation'),'release manifest points to current PWA cache'); +check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.2'); check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=12000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries'); -check(runtime.includes("const VERSION = '17.7.1'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); +check(runtime.includes("const VERSION = '17.7.2'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards'); check(runtime.includes('refreshSupportWorkspace')&&runtime.includes('sun_dev_support_snapshot'),'cloud exposes lightweight read-only support refresh'); check(runtime.includes('DEV_ADMIN_TTL_MS=30000')&&hotfix.includes('checkPlatformAdmin?.(false)')&&hotfix.includes('},10000);'),'developer access checks are throttled'); check(performance.includes('pendingImageRoots')&&performance.includes('queueImageScan')&&ux.includes('},5000);'),'background DOM maintenance is batched/throttled'); check(runtime.includes('explicitTemplate')&&runtime.includes("OFFER_TEMPLATE_IDS.has(explicitTemplate)"),'per-order proposal template survives render and PDF'); check(runtime.includes("else if(id==='midnight-glass')")&&runtime.includes("else if(id==='emerald-gold')")&&runtime.includes("if(id==='editorial-grid')"),'existing proposal layout sequences remain available'); check(runtime.includes('data-template-mini')&&ux.includes('data-template-mini'),'global and per-offer selectors show structural PDF previews'); -check(pkg.version==='17.7.1','package version is v17.7.1'); +check(pkg.version==='17.7.2','package version is v17.7.2'); check(classic.includes("const VERSION='17.6.7'")&&classic.includes('CLASSIC_IDS')&&classic.includes('ARCHIVE_IDS'),'classic proposal PDF module is v17.6.7'); check(runtime.includes("'warm-sun'")&&runtime.includes("'bento-cards'")&&runtime.includes("'event-story'")&&runtime.includes("'personal-letter'")&&runtime.includes("'event-ticket'")&&runtime.includes("'solar-experience'"),'10 classic proposal designs are available'); check(runtime.includes("'midnight-compact'")&&runtime.includes("'black-gold'")&&runtime.includes("'neon-emerald'"),'hidden archive proposal templates are restored'); @@ -64,15 +64,15 @@ check(offerWorkspace.includes("const VERSION='17.6.9'")&&offerWorkspace.includes check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace.includes('Оформление PDF')&&offerWorkspace.includes('data-offer-gallery-slot'),'offer workspace separates preview/editor/templates and exposes two gallery uploads'); check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer'); check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes'); -check(pkg.version==='17.7.1','package version is v17.7.1'); -check(index.includes('20260909-v17-7-1-data-layer-adoption'),'index cache bust is v17.7.0'); -check(sw.includes('v76-20260909-v17-7-1-data-layer-adoption')&&sw.includes('data-layer-v1771.js')&&sw.includes('server-automation-v1770.js'),'PWA caches architecture foundation modules'); -check(fs.existsSync(path.join(root,'public/core/data-layer-v1771.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); +check(pkg.version==='17.7.2','package version is v17.7.2'); +check(index.includes('20260909-v17-7-2-clients-foundation'),'index cache bust is v17.7.2'); +check(sw.includes('v77-20260909-v17-7-2-clients-foundation')&&sw.includes('data-layer-v1772.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.2 client foundation modules'); +check(fs.existsSync(path.join(root,'public/core/data-layer-v1772.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); -check(runtime.includes("const VERSION = '17.7.1'")&&runtime.includes("v17.7.1 Data Layer Adoption"),'stability logger reports v17.7.1'); +check(runtime.includes("const VERSION = '17.7.2'")&&runtime.includes("v17.7.2 Clients Foundation"),'stability logger reports v17.7.2'); check(fs.existsSync(path.join(root,'ops/sql/SUPABASE-V17.7.0-SERVER-ORDER-AUTOMATION.sql'))&&fs.existsSync(path.join(root,'ops/sql/SUPABASE-V17.7.0-ERROR-TELEMETRY-HYGIENE.sql')),'v17.7.0 server migrations are versioned'); -check(dataLayer.includes("const VERSION='17.7.1'")&&dataLayer.includes('catalogRepo')&&dataLayer.includes('CateriumDataV1770=api'),'v17.7.1 data layer exposes order/catalog repositories with compatibility alias'); +check(dataLayer.includes("const VERSION='17.7.2'")&&dataLayer.includes('catalogRepo')&&dataLayer.includes('clientsRepo')&&dataLayer.includes('CateriumDataV1771=api'),'v17.7.2 data layer exposes order/catalog/client repositories with compatibility aliases'); check(legacy.includes("persistOrders('order.save')")&&legacy.includes("persistOrders('order.status')")&&legacy.includes("persistOrders('order.delete')")&&legacy.includes("persistCatalog('catalog.save')")&&legacy.includes("persistCatalog('catalog.delete')"),'base legacy CRUD routes through data layer'); -check(releaseManifest.dataLayerPhase===2&&releaseManifest.dataLayerLegacyOrderWrites===true&&releaseManifest.dataLayerLegacyCatalogWrites===true,'release manifest records data layer phase 2'); +check(releaseManifest.dataLayerPhase===3&&releaseManifest.clientDataLayer===true&&releaseManifest.clientLegacyDoubleWrite===true,'release manifest records client data layer phase 3'); if(bad)process.exit(1); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index b08c03f..964319e 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -6,7 +6,7 @@ const readPub=p=>fs.readFileSync(path.join(pub,p),'utf8'); const readRoot=p=>fs.readFileSync(path.join(root,p),'utf8'); const fail=m=>{console.error('FAIL:',m);process.exitCode=1}; const ok=m=>console.log('OK:',m); -const html=readPub('index.html'),legacy=readPub('legacy/bootstrap.js'),runtime=readPub('app-runtime.js'),safe=readPub('core/sun-safe.js'),sw=readPub('service-worker.js'),performance=readPub('core/performance.js'),dataLayer=readPub('core/data-layer-v1771.js'),serverAutomation=readPub('core/server-automation-v1770.js'),ops=readPub('core/ops-ux-v1762.js'),hotfix=readPub('core/hotfix-v1763.js'),ux=readPub('core/ux-fixes-v1764.js'),classic=readPub('core/classic-offer-pdf-v1767.js'),developerUX=readPub('core/developer-console-v1768.js'),offerWorkspace=readPub('core/offer-workspace-v1769.js'); +const html=readPub('index.html'),legacy=readPub('legacy/bootstrap.js'),runtime=readPub('app-runtime.js'),safe=readPub('core/sun-safe.js'),sw=readPub('service-worker.js'),performance=readPub('core/performance.js'),dataLayer=readPub('core/data-layer-v1772.js'),serverAutomation=readPub('core/server-automation-v1770.js'),ops=readPub('core/ops-ux-v1762.js'),hotfix=readPub('core/hotfix-v1763.js'),ux=readPub('core/ux-fixes-v1764.js'),classic=readPub('core/classic-offer-pdf-v1767.js'),developerUX=readPub('core/developer-console-v1768.js'),offerWorkspace=readPub('core/offer-workspace-v1769.js'); if(!html.includes('core/sun-safe.js'))fail('SunSafe must load before legacy modules');else ok('shared SunSafe loaded'); if(html.includes('offer-gallery-data.js')||fs.existsSync(path.join(pub,'offer-gallery-data.js')))fail('blocking offer-gallery-data.js still present');else ok('base64 gallery removed'); for(const raw of ['${b.name}','${o.event}','${o.address||','value="${x[0]}"','value="${x[2]}"']) if(legacy.includes(raw)) fail(`legacy bootstrap contains raw HTML interpolation ${raw}`); @@ -28,15 +28,15 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('v17-7-1-data-layer-adoption')||!sw.includes('data-layer-v1771.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated to v17.7.0'); -if(html.includes('20260907-v17-6-0-stability-security')||!html.includes('20260909-v17-7-1-data-layer-adoption')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); +if(!sw.includes('v17-7-2-clients-foundation')||!sw.includes('data-layer-v1772.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated to v17.7.0'); +if(html.includes('20260907-v17-6-0-stability-security')||!html.includes('20260909-v17-7-2-clients-foundation')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current'); if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present'); if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present'); if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed'); if(!performance.includes('MEMORY_REFRESH_MS=30000')||!performance.includes('MEMORY_TIMEOUT_MS=8000')||!performance.includes('memoryPromise'))fail('Developer Console bounded refresh controls missing');else ok('Developer Console bounded refresh controls present'); if(!performance.includes('pendingImageRoots')||!performance.includes('queueImageScan'))fail('batched image mutation scanning missing');else ok('image mutation scanning is batched'); -if(!performance.includes('loadDataLayer')||!performance.includes('data-layer-v1771.js')||!performance.includes('loadServerAutomation'))fail('v17.7.1 data layer loaders missing');else ok('v17.7.1 data layer loaders present'); -if(!dataLayer.includes("const VERSION='17.7.1'")||!dataLayer.includes('ordersRepo')||!dataLayer.includes('catalogRepo')||!dataLayer.includes('applyServerOrders')||!dataLayer.includes('CateriumDataV1770=api'))fail('v17.7.1 data layer missing');else ok('v17.7.1 data layer present'); +if(!performance.includes('loadDataLayer')||!performance.includes('data-layer-v1772.js')||!performance.includes('loadServerAutomation'))fail('v17.7.1 data layer loaders missing');else ok('v17.7.1 data layer loaders present'); +if(!dataLayer.includes("const VERSION='17.7.2'")||!dataLayer.includes('ordersRepo')||!dataLayer.includes('catalogRepo')||!dataLayer.includes('clientsRepo')||!dataLayer.includes('sun_v17_save_client_v1772')||!dataLayer.includes('CateriumDataV1771=api'))fail('v17.7.2 data layer missing');else ok('v17.7.2 data layer present'); if(!serverAutomation.includes("const VERSION='17.7.0'")||!serverAutomation.includes("rpc('sun_run_order_automation'")||!serverAutomation.includes('applyServerChanges'))fail('v17.7.0 server automation client missing');else ok('v17.7.0 server automation client present'); if(!performance.includes('ux-fixes-v1764.js')||!performance.includes('SunUXFixV1764'))fail('UX fix loader missing');else ok('UX fix loader present'); if(!performance.includes('hotfix-v1763.js')||!performance.includes('SunHotfixV1763'))fail('v17.6.3 hotfix loader missing');else ok('v17.6.3 hotfix loader present'); @@ -44,7 +44,7 @@ if(!performance.includes('ops-ux-v1762.js')||!performance.includes('SunOpsUXV176 for(const marker of ['patchDeveloperOpen','enhanceDeveloperGate','data-saas-admin','stopImmediatePropagation','instanceof HTMLElement']) if(!hotfix.includes(marker))fail(`developer/SaaS hotfix marker missing: ${marker}`);else ok(`developer/SaaS hotfix marker: ${marker}`); for(const marker of ['SUPPORT_POLL_MS=20000','supportReadPermission','refreshSupportWorkspace','sun-menu-editor-v1762','showCalendarDay',"ROUTE_BASE_KEY='sunRouteBaseV1'",'showRouteOrder','routeOpenYandex']) if(!ops.includes(marker)) fail(`ops UX marker missing: ${marker}`); else ok(`ops UX marker: ${marker}`); for(const marker of ["AUTO_DELAY_MS=60*1000","order.prepayment=total","order.status='Отдан заказчику'",'sunAutoCompletedAt','classificationDate','persistOfferTemplate','clientOfferTemplateId','offerTemplateId','sun-v1764-menu-icon','CateriumServerAutomationV1770?.enabled']) if(!ux.includes(marker))fail(`UX compatibility marker missing: ${marker}`);else ok(`UX compatibility marker: ${marker}`); -for(const marker of ['CLOUD_RPC_TIMEOUT_MS=12000','CLOUD_CONFLICT_MAX_RETRIES=4','refreshSupportWorkspace',"const VERSION = '17.7.1'",'ERROR_DEDUPE_MS=5*60*1000','DEV_ADMIN_TTL_MS=30000']) if(!runtime.includes(marker))fail(`stability marker missing: ${marker}`);else ok(`stability marker: ${marker}`); +for(const marker of ['CLOUD_RPC_TIMEOUT_MS=12000','CLOUD_CONFLICT_MAX_RETRIES=4','refreshSupportWorkspace',"const VERSION = '17.7.2'",'ERROR_DEDUPE_MS=5*60*1000','DEV_ADMIN_TTL_MS=30000']) if(!runtime.includes(marker))fail(`stability marker missing: ${marker}`);else ok(`stability marker: ${marker}`); if(!hotfix.includes('checkPlatformAdmin?.(false)')||!hotfix.includes('},10000);'))fail('Developer fallback polling is still aggressive');else ok('Developer fallback polling is throttled'); if(!ux.includes('sunMenuIconV1766')||!ux.includes('sun-offer-template-mini-editorial-grid')||!runtime.includes('explicitTemplate'))fail('v17.6.6 proposal/menu markers missing');else ok('v17.6.6 proposal/menu markers present'); if(!classic.includes("const VERSION='17.6.7'")||!classic.includes('CLASSIC_IDS')||!classic.includes('ARCHIVE_IDS')||!classic.includes('renderPages'))fail('v17.6.7 classic PDF module missing');else ok('v17.6.7 classic PDF module present');