Add isolated trial demo boxes with TTK, stock and purchasing scenario
@ -11,7 +11,7 @@
|
||||
"serverReady": true,
|
||||
"workspaceAutoDiscovery": true,
|
||||
"invitesTemporarilyDisabled": false,
|
||||
"pwaCache": "v90-20260918-login-recovery",
|
||||
"pwaCache": "v91-20260918-trial-demo",
|
||||
"fullOfferDescriptions": true,
|
||||
"dynamicOfferRows": true,
|
||||
"pdfOfferDescriptionFix": true,
|
||||
@ -385,5 +385,13 @@
|
||||
"supabaseProjectRef": "usfjwhztqoopzzfmfbis",
|
||||
"recoveryMigration": "20260917150000_fresh_caterium",
|
||||
"anonymousAccessDisabled": true,
|
||||
"clientCacheTenantIsolation": true
|
||||
"clientCacheTenantIsolation": true,
|
||||
"trialDemo": {
|
||||
"version": 1,
|
||||
"boxes": 10,
|
||||
"stockProducts": 43,
|
||||
"suppliers": 5,
|
||||
"autoNewTrial": true,
|
||||
"preservesSolnce": true
|
||||
}
|
||||
}
|
||||
|
||||
13
docs/releases/2026-09-18-TRIAL-DEMO.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Trial demo catalog
|
||||
|
||||
New full-feature trial workspaces start with 10 fictional catering boxes, generated photos, ingredient quantities, TTK gross/net tables, preparation steps and allergens. The 43 stock products include purchase prices and partial opening balances, linked to 5 fictional suppliers. Ordinary paid companies remain empty; the Solnce company is excluded.
|
||||
|
||||
An empty trial or the developer's own empty company can install the set from New Order. Installation requires an active admin membership and a writable subscription, locks the workspace, preserves other data, and does not overwrite a populated catalog/stock/supplier ledger. Repeating installation leaves the existing data unchanged. No customer or order is installed automatically.
|
||||
|
||||
The guide creates one clearly marked sample order for tomorrow (initial total 9,700 RUB). It cannot auto-complete/pay and has no invented customer identity. Users can test shortages, receipts and recipe-based write-off. Photos and prices are illustrative; TTK costs cover ingredients and packaging only.
|
||||
|
||||
Cloud hydration now refreshes the inventory closure; owner onboarding pulls the server seed before saving the company profile. PWA cache: v91-20260918-trial-demo.
|
||||
|
||||
Source: `ops/demo/trial-demo-data.mjs`; rebuild with `npm run build:demo`. Image prompts and built-in generation provenance: `ops/demo/image-prompts.json`. Database migration: `20260918010000_trial_demo_catalog.sql`.
|
||||
|
||||
Validation: SQL smoke covers automatic trial creation, all TTK/stock links, idempotency, populated-catalog protection, Solnce exclusion, foreign/viewer/anonymous denial and developer-owned workspace without granting platform access. Desktop/mobile integration covers TTK display, images, one sample order, shortages, stock receipt and repeated write-off protection; owner onboarding preserves the server demo.
|
||||
7
ops/demo/build-trial-demo.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import {demo} from './trial-demo-data.mjs';
|
||||
const root=new URL('../../',import.meta.url);
|
||||
const template=fs.readFileSync(new URL('trial-demo.sql',import.meta.url),'utf8');
|
||||
fs.writeFileSync(new URL('public/demo/catalog-v1.json',root),JSON.stringify(demo,null,2)+'\n');
|
||||
fs.writeFileSync(new URL('supabase/migrations/20260918010000_trial_demo_catalog.sql',root),template.replace('__DEMO_JSON__',JSON.stringify(demo)));
|
||||
console.log(`Built ${demo.boxes.length} boxes, ${demo.stock.length} products, ${demo.suppliers.length} suppliers.`);
|
||||
45
ops/demo/image-prompts.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"tool": "built-in image_gen",
|
||||
"prompts": [
|
||||
{
|
||||
"id": "bruschetta-tomato",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 24 small toasted baguette bruschettas topped with diced red tomatoes, basil and olive oil in four neat rows. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "salmon-cream",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 16 rye bread canapes with folds of lightly salted salmon, cream cheese, cucumber and dill, four by four. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "chicken-sandwich",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 12 small triangular chicken sandwiches with lettuce, tomato and cream cheese, neatly lined up. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "caprese",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 20 canape skewers with white mini mozzarella balls, red cherry tomatoes and basil, five rows of four. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "mushroom-tartlet",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 20 small golden pastry tartlets filled with mushrooms, cream and melted cheese, four rows of five. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "turkey-wrap",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 24 tortilla pinwheel rolls with turkey, cream cheese, lettuce and red bell pepper, neatly arranged. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "cheese-fruit",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of a curated cheese assortment with brie wedges, gouda cubes, blue cheese, green grapes, walnuts, a tiny honey cup and crackers. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "meat-assortment",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of a meat assortment of folded salami, roast beef and turkey ham, cornichons, black olives and a tiny mustard cup. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "vegetables-hummus",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of colorful fresh carrot sticks, cucumber sticks, bell pepper strips, cherry tomatoes and a central small kraft cup of hummus. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
},
|
||||
{
|
||||
"id": "berry-dessert",
|
||||
"prompt": "Use case: product-mockup. Asset type: catering demo catalog photo. Create a photorealistic square product photograph of 16 miniature clear dessert cups of cheesecake cream, biscuit crumble, fresh strawberries and blueberries, four by four. One open matte ivory catering box with neat white paper dividers where appropriate, on a light white marble table. Three-quarter overhead angle, entire box visible with narrow margins, soft daylight, appetizing realistic textures, elegant simple commercial food photography. No people, utensils, logos, letters, numbers, labels or watermarks. This is a distinct illustrative product image for a demo catalog."
|
||||
}
|
||||
]
|
||||
}
|
||||
79
ops/demo/trial-demo-data.mjs
Normal file
@ -0,0 +1,79 @@
|
||||
// Fictional training recipes and prices. No customer or Solnce catalog data.
|
||||
const suppliers=[
|
||||
['fresh','Демо: овощи и молочные продукты'],['protein','Демо: мясо и рыба'],
|
||||
['bakery','Демо: пекарня'],['grocery','Демо: бакалея'],['pack','Демо: упаковка']
|
||||
].map(([key,name])=>({id:`demo-v1-supplier-${key}`,name,active:true,category:'Учебный поставщик',contact:'',phone:'',email:'',address:'',deliveryDays:'По договорённости',minOrder:0,note:'Вымышленный поставщик для знакомства с приложением.'}));
|
||||
// key, product name, purchase unit price, supplier, unit
|
||||
const products=[
|
||||
['baguette','Багет',240,'bakery'],['tomato','Томаты',260,'fresh'],['basil','Базилик',1500,'fresh'],
|
||||
['oil','Масло оливковое',1100,'grocery'],['rye','Хлеб ржаной',220,'bakery'],
|
||||
['salmon','Лосось слабосолёный',2400,'protein'],['cream','Сыр сливочный',850,'fresh'],
|
||||
['cucumber','Огурцы',220,'fresh'],['dill','Укроп',700,'fresh'],['toast','Хлеб тостовый',240,'bakery'],
|
||||
['chicken','Филе курицы готовое',850,'protein'],['lettuce','Салат листовой',700,'fresh'],
|
||||
['mozzarella','Моцарелла мини',950,'fresh'],['cherry','Томаты черри',450,'fresh'],
|
||||
['tartlet','Тарталетки готовые',750,'bakery'],['mushroom','Шампиньоны',340,'fresh'],
|
||||
['cooking-cream','Сливки',500,'fresh'],['gouda','Сыр гауда',900,'fresh'],['onion','Лук репчатый',80,'fresh'],
|
||||
['tortilla','Тортилья',450,'bakery'],['turkey','Ветчина из индейки',1000,'protein'],['pepper','Перец сладкий',380,'fresh'],
|
||||
['brie','Сыр бри',1600,'fresh'],['blue','Сыр с голубой плесенью',1700,'fresh'],['grape','Виноград',420,'fresh'],
|
||||
['walnut','Орех грецкий очищенный',1000,'grocery'],['honey','Мёд',800,'grocery'],['cracker','Крекер',500,'bakery'],
|
||||
['salami','Салями',1400,'protein'],['beef','Ростбиф готовый',2000,'protein'],['pickle','Корнишоны',400,'grocery'],
|
||||
['olive','Маслины без косточек',650,'grocery'],['mustard','Горчица',350,'grocery'],['carrot','Морковь',90,'fresh'],
|
||||
['hummus','Хумус готовый',600,'grocery'],['biscuit','Крошка бисквитная',500,'bakery'],
|
||||
['strawberry','Клубника',750,'fresh'],['blueberry','Голубика',1300,'fresh'],['sugar','Пудра сахарная',160,'grocery'],
|
||||
['box','Коробка для кейтеринга',65,'pack','шт.'],['skewer','Шпажка бамбуковая',2,'pack','шт.'],
|
||||
['cup','Стаканчик десертный',9,'pack','шт.'],['sauce-cup','Соусник',7,'pack','шт.']
|
||||
].map(([key,name,cost,supplier,unit='кг'])=>({key,id:`demo-v1-stock-${key}`,name,cost,unit,supplierId:`demo-v1-supplier-${supplier}`}));
|
||||
const byKey=new Map(products.map(p=>[p.key,p]));
|
||||
// Product quantities are gross/net in grams; packaging is in pieces.
|
||||
const recipes=[
|
||||
{id:'bruschetta-tomato',name:'Брускетты с томатами и базиликом',price:2200,pieces:24,
|
||||
rows:[['baguette',480,480],['tomato',330,300],['basil',26,24],['oil',36,36]],
|
||||
composition:['24 брускетты с томатами, базиликом и оливковым маслом'],allergens:['Глютен'],
|
||||
steps:['Нарезать багет на 24 одинаковых ломтика и подсушить.','Подготовить томаты и базилик, смешать с маслом.','Распределить начинку по ломтикам, уложить в коробку.']},
|
||||
{id:'salmon-cream',name:'Канапе с лососем и сливочным сыром',price:3200,pieces:16,
|
||||
rows:[['rye',240,240],['salmon',240,240],['cream',160,160],['cucumber',84,72],['dill',10,8]],
|
||||
composition:['16 канапе на ржаном хлебе с лососем, сливочным сыром и огурцом'],allergens:['Рыба','Молоко','Глютен'],
|
||||
steps:['Вырезать из ржаного хлеба 16 основ.','Нанести сливочный сыр, добавить ломтики огурца и лосося.','Украсить укропом и уложить в коробку.']},
|
||||
{id:'chicken-sandwich',name:'Мини-сэндвичи с курицей',price:2400,pieces:12,
|
||||
rows:[['toast',360,360],['chicken',240,240],['cream',120,120],['tomato',92,84],['lettuce',45,36]],
|
||||
composition:['12 мини-сэндвичей с готовым куриным филе, томатами, салатом и сливочным сыром'],allergens:['Глютен','Молоко'],
|
||||
steps:['Нарезать готовое куриное филе, подготовить овощи и салат.','Собрать сэндвичи из тостового хлеба, сыра, курицы и овощей.','Нарезать на 12 одинаковых мини-сэндвичей и упаковать.']},
|
||||
{id:'caprese',name:'Канапе «Капрезе»',price:2100,pieces:20,
|
||||
rows:[['mozzarella',360,360],['cherry',308,280],['basil',22,20],['oil',40,40],['skewer',20,20]],
|
||||
composition:['20 шпажек с моцареллой, черри и базиликом'],allergens:['Молоко'],
|
||||
steps:['Подготовить черри и базилик, обсушить моцареллу.','Собрать 20 шпажек, распределив ингредиенты поровну.','Добавить оливковое масло, уложить в коробку.']},
|
||||
{id:'mushroom-tartlet',name:'Тарталетки с грибами и сыром',price:2300,pieces:20,
|
||||
rows:[['tartlet',200,200],['mushroom',440,300],['cooking-cream',100,100],['gouda',120,120],['onion',88,60],['oil',20,20]],
|
||||
composition:['20 тарталеток с шампиньонами, сливками и сыром'],allergens:['Глютен','Молоко'],
|
||||
steps:['Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.','Добавить сливки и распределить начинку по 20 тарталеткам.','Добавить сыр, запечь до расплавления и упаковать после охлаждения.']},
|
||||
{id:'turkey-wrap',name:'Роллы из тортильи с индейкой',price:2700,pieces:25,
|
||||
rows:[['tortilla',320,320],['turkey',320,320],['cream',180,180],['lettuce',75,60],['pepper',150,120]],
|
||||
composition:['25 мини-роллов с ветчиной из индейки, сливочным сыром, перцем и салатом'],allergens:['Глютен','Молоко'],
|
||||
steps:['Подготовить салат и перец, нарезать ветчину.','Нанести сыр на тортильи, распределить начинку и свернуть плотные рулеты.','Нарезать на 25 порций и уложить срезом вверх.']},
|
||||
{id:'cheese-fruit',name:'Сырный бокс с виноградом и орехами',price:2900,pieces:0,
|
||||
rows:[['brie',200,200],['gouda',180,180],['blue',120,120],['grape',176,160],['walnut',50,50],['honey',40,40],['cracker',50,50],['sauce-cup',1,1]],
|
||||
composition:['Бри, гауда и сыр с голубой плесенью','Виноград, грецкий орех, мёд и крекер'],allergens:['Молоко','Орехи','Глютен'],
|
||||
steps:['Нарезать сыры порционными кусочками.','Подготовить виноград, переложить мёд в соусник.','Разложить сыры, виноград, орехи и крекер отдельными секциями.']},
|
||||
{id:'meat-assortment',name:'Мясное ассорти с корнишонами',price:3200,pieces:0,
|
||||
rows:[['salami',180,180],['beef',220,220],['turkey',150,150],['pickle',90,80],['olive',45,40],['mustard',30,30],['sauce-cup',1,1]],
|
||||
composition:['Салями, готовый ростбиф и ветчина из индейки','Корнишоны, маслины и горчица'],allergens:['Горчица'],
|
||||
steps:['Нарезать готовые мясные продукты тонкими ломтиками.','Обсушить корнишоны и маслины, переложить горчицу в соусник.','Разложить ассорти секциями, добавить гарниры.']},
|
||||
{id:'vegetables-hummus',name:'Овощной бокс с хумусом',price:2100,pieces:0,
|
||||
rows:[['carrot',300,250],['cucumber',275,250],['pepper',250,200],['cherry',165,150],['hummus',150,150],['sauce-cup',1,1]],
|
||||
composition:['Морковь, огурцы, сладкий перец и черри','Хумус в отдельном соуснике'],allergens:['Кунжут в составе хумуса'],
|
||||
steps:['Вымыть и подготовить овощи; очистить морковь и перец.','Нарезать овощи палочками, черри оставить целыми.','Переложить хумус в соусник и разложить овощи вокруг него.']},
|
||||
{id:'berry-dessert',name:'Ягодные мини-десерты',price:2800,pieces:16,
|
||||
rows:[['biscuit',160,160],['cream',320,320],['cooking-cream',160,160],['strawberry',176,160],['blueberry',84,80],['sugar',80,80],['cup',16,16]],
|
||||
composition:['16 десертов со сливочным кремом, бисквитом, клубникой и голубикой'],allergens:['Молоко','Глютен','Яйцо в составе бисквита'],
|
||||
steps:['Подготовить ягоды, разделить готовый бисквит на крошку.','Смешать сливочный сыр, сливки и сахарную пудру в крем.','Собрать 16 стаканчиков слоями: бисквит, крем, ягоды.']}
|
||||
];
|
||||
const round=n=>Math.round(n*1000)/1000;
|
||||
const totals=new Map();
|
||||
export const boxes=recipes.map((r,index)=>{
|
||||
const rows=[...r.rows,['box',1,1]].map(([key,gross,net])=>{const p=byKey.get(key),scale=p.unit==='кг'?1000:1;const row={productId:p.id,name:p.name,unit:p.unit,gross:round(gross/scale),net:round(net/scale),unitCost:p.cost};totals.set(key,round((totals.get(key)||0)+row.gross));return row});
|
||||
const outputGrams=Math.round(rows.filter(p=>p.unit==='кг').reduce((s,p)=>s+p.net*1000,0));
|
||||
return {id:`demo-v1-${r.id}`,name:r.name,price:r.price,category:0,catalogSection:'Демонстрационные боксы',pieces:r.pieces,weight:`${outputGrams} г`,photo:`demo/images/${r.id}.png`,composition:r.composition,ingredients:rows.map(p=>[p.name,p.gross,p.unit]),demo:true,ttk:{number:`ДЕМО-${String(index+1).padStart(2,'0')}`,basis:'На 1 готовый бокс',outputGrams,pieces:r.pieces,rows,steps:r.steps,allergens:r.allergens,ingredientCost:Math.round(rows.reduce((s,p)=>s+p.gross*p.unitCost,0)*100)/100,note:'Учебные нормы и цены для знакомства с приложением.'}};
|
||||
});
|
||||
export const stock=products.map((p,index)=>({id:p.id,name:p.name,unit:p.unit,cost:p.cost,qty:(p.unit==='шт.'?Math.floor:round)((totals.get(p.key)||0)*[0,0.45,2,0.7][index%4]),min:p.unit==='кг'?0.1:2,supplierId:p.supplierId,lastPurchaseDate:'',lastPurchasePrice:0}));
|
||||
export {suppliers};
|
||||
export const demo={version:1,batch:'trial-demo-v1',boxes,stock,suppliers,scenario:{lines:[{id:'demo-v1-bruschetta-tomato',qty:2},{id:'demo-v1-salmon-cream',qty:1},{id:'demo-v1-caprese',qty:1}]}};
|
||||
84
ops/demo/trial-demo.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- Generated by ops/demo/build-trial-demo.mjs. Fictional training catalog only.
|
||||
begin;
|
||||
|
||||
create or replace function public.caterium_trial_demo_seed_v1()
|
||||
returns jsonb language sql immutable security definer set search_path=public,pg_temp
|
||||
as $fn$ select $demo$__DEMO_JSON__$demo$::jsonb $fn$;
|
||||
revoke all on function public.caterium_trial_demo_seed_v1() from public,anon,authenticated;
|
||||
|
||||
create or replace function public.caterium_trial_demo_status(p_workspace uuid)
|
||||
returns jsonb language plpgsql stable security definer set search_path=public,pg_temp
|
||||
as $fn$
|
||||
declare
|
||||
v_storage jsonb; v_empty boolean:=true; v_key text; v_value jsonb;
|
||||
v_allowed boolean; v_installed boolean;
|
||||
begin
|
||||
if auth.uid() is null or public.sun_member_role(p_workspace) is null then raise exception 'Access denied'; end if;
|
||||
select coalesce(payload->'storage','{}'::jsonb) into v_storage from public.sun_app_state where workspace_id=p_workspace;
|
||||
v_installed:=coalesce(v_storage#>>'{sunTrialDemoV1,v,version}','')='1';
|
||||
foreach v_key in array array['sunBoxes','sunStock','sunSuppliers','sunStockMoves'] loop
|
||||
v_value:=v_storage#>array[v_key,'v'];
|
||||
if v_value is not null and v_value<>'[]'::jsonb then v_empty:=false; end if;
|
||||
if v_storage ? v_key and (jsonb_typeof(v_storage->v_key)<>'object' or v_value is null) then v_empty:=false; end if;
|
||||
end loop;
|
||||
v_allowed:=public.sun_member_role(p_workspace)='admin'
|
||||
and public.sun_subscription_access_mode(p_workspace)='full'
|
||||
and (exists(select 1 from public.sun_workspace_subscriptions where workspace_id=p_workspace and status='trialing') or public.sun_is_platform_admin())
|
||||
and public.sun_workspace_has_feature(p_workspace,'catalog_edit')
|
||||
and public.sun_workspace_has_feature(p_workspace,'stock')
|
||||
and public.sun_workspace_has_feature(p_workspace,'suppliers')
|
||||
and not exists(select 1 from public.sun_workspace_members m join public.caterium_sidebar_brand_assignment a on a.user_id=m.user_id where m.workspace_id=p_workspace);
|
||||
return jsonb_build_object('eligible',coalesce(v_allowed,false),'installed',v_installed,'empty',v_empty,'canInstall',coalesce(v_allowed,false) and v_empty and not v_installed);
|
||||
end $fn$;
|
||||
revoke all on function public.caterium_trial_demo_status(uuid) from public,anon;
|
||||
grant execute on function public.caterium_trial_demo_status(uuid) to authenticated;
|
||||
|
||||
create or replace function public.caterium_install_trial_demo(p_workspace uuid)
|
||||
returns jsonb language plpgsql security definer set search_path=public,pg_temp
|
||||
as $fn$
|
||||
declare
|
||||
v_state public.sun_app_state%rowtype; v_status jsonb; v_seed jsonb; v_storage jsonb; v_moves jsonb; v_marker jsonb;
|
||||
begin
|
||||
if auth.uid() is null or public.sun_member_role(p_workspace) is distinct from 'admin' then raise exception 'Administrator membership required'; end if;
|
||||
perform 1 from public.sun_workspaces where id=p_workspace for update;
|
||||
v_status:=public.caterium_trial_demo_status(p_workspace);
|
||||
if not (v_status->>'eligible')::boolean then raise exception 'Демонстрационная база доступна в пробной или собственной тестовой компании разработчика'; end if;
|
||||
if (v_status->>'installed')::boolean then return v_status; end if;
|
||||
if not (v_status->>'empty')::boolean then raise exception 'Каталог, склад или поставщики уже заполнены. Данные сохранены без изменений'; end if;
|
||||
select * into v_state from public.sun_app_state where workspace_id=p_workspace for update;
|
||||
if not found then raise exception 'Рабочая база не найдена'; end if;
|
||||
v_seed:=public.caterium_trial_demo_seed_v1();
|
||||
select coalesce(jsonb_agg(jsonb_build_object('id','demo-v1-opening-'||(p->>'id'),'productId',p->>'id','productName',p->>'name','unit',p->>'unit',
|
||||
'type','set','qty',p->'qty','setTo',p->'qty','cost',p->'cost','date',current_date::text,'createdAt',now(),
|
||||
'supplierId',p->>'supplierId','note','Учебный начальный остаток')),'[]'::jsonb) into v_moves
|
||||
from jsonb_array_elements(v_seed->'stock') p where (p->>'qty')::numeric>0;
|
||||
v_marker:=jsonb_build_object('version',1,'batch','trial-demo-v1','installedAt',now(),'scenario',v_seed->'scenario');
|
||||
v_storage:=coalesce(v_state.payload->'storage','{}'::jsonb)||jsonb_build_object(
|
||||
'sunBoxes',jsonb_build_object('t','j','v',v_seed->'boxes'),
|
||||
'sunStock',jsonb_build_object('t','j','v',v_seed->'stock'),
|
||||
'sunSuppliers',jsonb_build_object('t','j','v',v_seed->'suppliers'),
|
||||
'sunStockMoves',jsonb_build_object('t','j','v',v_moves),
|
||||
'sunTrialDemoV1',jsonb_build_object('t','j','v',v_marker));
|
||||
perform public.sun_save_app_state_v17(p_workspace,jsonb_set(v_state.payload,'{storage}',v_storage),'trial-demo-v1',v_state.revision);
|
||||
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details)
|
||||
values(auth.uid(),'trial_demo.install',p_workspace,jsonb_build_object('version',1,'boxes',10));
|
||||
return public.caterium_trial_demo_status(p_workspace);
|
||||
end $fn$;
|
||||
revoke all on function public.caterium_install_trial_demo(uuid) from public,anon;
|
||||
grant execute on function public.caterium_install_trial_demo(uuid) to authenticated;
|
||||
|
||||
create or replace function public.caterium_seed_new_trial()
|
||||
returns trigger language plpgsql security definer set search_path=public,pg_temp
|
||||
as $fn$
|
||||
begin
|
||||
if coalesce((public.caterium_trial_demo_status(new.workspace_id)->>'canInstall')::boolean,false) then
|
||||
perform public.caterium_install_trial_demo(new.workspace_id);
|
||||
end if;
|
||||
return new;
|
||||
end $fn$;
|
||||
revoke all on function public.caterium_seed_new_trial() from public,anon,authenticated;
|
||||
drop trigger if exists caterium_seed_new_trial on public.caterium_trial_redemptions;
|
||||
create trigger caterium_seed_new_trial after insert on public.caterium_trial_redemptions
|
||||
for each row execute function public.caterium_seed_new_trial();
|
||||
notify pgrst,'reload schema';
|
||||
commit;
|
||||
@ -4,7 +4,7 @@
|
||||
"version": "17.7.3",
|
||||
"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/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.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/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.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/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.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/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js && node --check public/core/cloud-transport.js && node --check public/core/trial-demo.js",
|
||||
"test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs",
|
||||
"check:release": "node tests/release-check.mjs",
|
||||
"check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db",
|
||||
@ -12,7 +12,8 @@
|
||||
"build:recovery": "node tests/recovery/bundle.mjs",
|
||||
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
|
||||
"test": "npm run check:deploy",
|
||||
"deploy:cloudflare-backup": "wrangler deploy"
|
||||
"deploy:cloudflare-backup": "wrangler deploy",
|
||||
"build:demo": "node ops/demo/build-trial-demo.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electric-sql/pglite": "0.5.8",
|
||||
|
||||
@ -391,7 +391,7 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
const pieceUnitRe=/^(?:шт\.?|пор\.?|кус\.?|шарик(?:а|ов|и)?|креманк(?:а|и|е)?)$/i;
|
||||
function ingredientUnitWeight(row){if(!Array.isArray(row))return 0;const direct=Math.max(0,Number(row[3]||0));if(direct)return direct;const m=String(row[0]||'').match(/[—–-]\s*(\d+(?:[.,]\d+)?)\s*г(?:\s*\/\s*шт\.?)?\s*$/i);return m?Math.max(0,Number(String(m[1]).replace(',','.'))):0}
|
||||
function cleanIngredientName(name){return String(name||'').replace(/\s*[—–-]\s*\d+(?:[.,]\d+)?\s*г(?:\s*\/\s*шт\.?)?\s*$/i,'').trim()}
|
||||
function inferBoxPieces(item){const explicit=Math.max(0,Number(item?.pieces||0));if(explicit)return Math.round(explicit);let sum=0;for(const row of item?.ingredients||[]){if(!Array.isArray(row))continue;const unit=String(row[2]||'').trim().toLowerCase();if(pieceUnitRe.test(unit))sum+=Math.max(0,Number(row[1]||0));}return Math.round(sum)}
|
||||
function inferBoxPieces(item){if(item?.demo&&item.pieces===0)return 0;const explicit=Math.max(0,Number(item?.pieces||0));if(explicit)return Math.round(explicit);let sum=0;for(const row of item?.ingredients||[]){if(!Array.isArray(row))continue;const unit=String(row[2]||'').trim().toLowerCase();if(pieceUnitRe.test(unit))sum+=Math.max(0,Number(row[1]||0));}return Math.round(sum)}
|
||||
function ingredientDisplayLine(row){if(!Array.isArray(row)||!String(row[0]||'').trim())return'';const name=cleanIngredientName(row[0]),qty=Math.max(0,Number(row[1]||0)),unit=String(row[2]||'').trim(),w=ingredientUnitWeight(row);if(/^поз\.?$/i.test(unit)&&qty<=1)return `${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`;const q=Number.isInteger(qty)?String(qty):qty.toLocaleString('ru-RU',{maximumFractionDigits:2});return `${q} ${unit} ${name}${w?` — ${w.toLocaleString('ru-RU')} г/шт.`:''}`.replace(/\s+/g,' ').trim()}
|
||||
function normalizeBoxItem(item,{cleanup=false}={}){if(!item||![0,5].includes(Number(item.category||0)))return item;const legacyComposition=Array.isArray(item.composition)?item.composition.slice():[];item.ingredients=Array.isArray(item.ingredients)?item.ingredients.filter(r=>Array.isArray(r)&&String(r[0]||'').trim()).map((r,index)=>{const row=[String(r[0]||'').trim(),Math.max(0,Number(r[1]||0)),String(r[2]||'').trim()||'шт.',Math.max(0,Number(r[3]||0))];if(!row[3])row[3]=ingredientUnitWeight(r)||ingredientUnitWeight([legacyComposition[index]||'',0,'',0]);return row;}):[];
|
||||
// Box №18 had a total row "25 шт. тостов ассорти" mixed into two real varieties. The total belongs in pieces, not in composition.
|
||||
@ -3202,13 +3202,17 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
const {data,error}=await client.rpc('sun_create_workspace',{p_name:name});if(error)throw error;
|
||||
const newId=String(data||'');if(!newId)throw new Error('Сервер не вернул ID новой компании.');
|
||||
await switchTenantLocal(newId);
|
||||
seedOfficialCatalogForFreshWorkspace();
|
||||
try{localStorage.setItem('sunCompanyProfileV1',JSON.stringify({name,createdAt:new Date().toISOString()}));}catch(_){}
|
||||
await adoptCurrentLocalAsWorkspace(newId);
|
||||
await loadMemberships();
|
||||
if(!workspace||String(workspace.id)!==newId){config.workspaceId=newId;saveConfig();selectWorkspace();}
|
||||
if(!workspace)throw new Error('Компания создана, но не появилась в списке доступных компаний.');
|
||||
const remote=await fetchRemoteRow();
|
||||
if(!remote?.payload?.storage)throw new Error('Компания создана, но её база не загрузилась. Повторите вход.');
|
||||
// The server owns trial initialization. Pull it before saving the company
|
||||
// name, otherwise an empty browser catalog would erase the seeded recipes.
|
||||
await applyPayload(remote.payload,true);
|
||||
const profile=JSON.parse(localStorage.getItem('sunCompanyProfileV1')||'{}');
|
||||
localStorage.setItem('sunCompanyProfileV1',JSON.stringify({...profile,name,createdAt:profile.createdAt||new Date().toISOString()}));
|
||||
await adoptCurrentLocalAsWorkspace(newId);
|
||||
const localPayload=await collectLocalPayload();
|
||||
const row=await upsertPayload(localPayload,remote?.revision??null);
|
||||
await setBaseline(row);
|
||||
@ -3331,12 +3335,33 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
await connectFromConfig(false);return true;
|
||||
}
|
||||
|
||||
async function installTrialDemo(){
|
||||
if(!client||!session||!workspace?.id||supportMode)throw new Error('Войдите в собственную рабочую компанию.');
|
||||
if(isSyncing)throw new Error('Дождитесь завершения синхронизации и повторите.');
|
||||
if(dirty){await syncNow({quiet:true});if(dirty)throw new Error('Сначала сохраните текущие изменения в облаке.');}
|
||||
const target=workspace.id;
|
||||
isSyncing=true;clearTimeout(syncTimer);
|
||||
try{
|
||||
const result=await sunCloudAwait(client.rpc('caterium_install_trial_demo',{p_workspace:target}),'Загрузка демонстрационной базы');
|
||||
if(result?.error)throw result.error;
|
||||
if(workspace?.id!==target)throw new Error('Компания сменилась. Откройте её заново.');
|
||||
const row=await fetchRemoteRow();
|
||||
if(row?.payload?.storage?.sunTrialDemoV1?.v?.version!==1)throw new Error('Не удалось получить демонстрационную базу. Повторите загрузку.');
|
||||
await backupBeforeCloud('Перед загрузкой демонстрационной базы');
|
||||
await applyPayload(row.payload,true);await setBaseline(row);
|
||||
config.migrated[target]=true;config.lastSync=row.updated_at||new Date().toISOString();saveConfig();dirty=false;
|
||||
setStatus('ready','Демонстрационная база загружена.');
|
||||
window.dispatchEvent(new CustomEvent('sun:cloud-sync-complete',{detail:{row:clone(row),payload:clone(row.payload),kind:'pull'}}));
|
||||
return result.data;
|
||||
}finally{isSyncing=false;renderCloudUI();}
|
||||
}
|
||||
function setAutoSyncDeveloper(enabled){config.autoSync=enabled!==false;saveConfig();if(config.autoSync&&workspace&&!supportMode)scheduleSync('автосинхронизация включена');renderCloudUI();return config.autoSync;}
|
||||
window.SunCloudV2={
|
||||
VERSION,
|
||||
signOut:signOutAndReload,
|
||||
adoptCurrentLocalAsWorkspace,
|
||||
createTrialWorkspace,
|
||||
installTrialDemo,
|
||||
acceptInvite:acceptInviteToken,
|
||||
syncNow:()=>syncNow({quiet:false}),
|
||||
pull:()=>pullRemote({replace:true,quiet:false}),
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
(()=>{
|
||||
'use strict';
|
||||
const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin']);
|
||||
const READ_RPCS=new Set(['sun_my_workspaces','sun_fetch_app_state','sun_is_platform_admin','caterium_trial_demo_status']);
|
||||
function create({upstream,proxy,timeout=7000,fallbackTimeout=4000}){
|
||||
const origin=new URL(upstream).origin;
|
||||
return async function(input,init={}){
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
(()=>{
|
||||
'use strict';
|
||||
const VERSION='17.7.3';
|
||||
const RELEASE='20260918-login-recovery';
|
||||
const RELEASE='20260918-trial-demo';
|
||||
|
||||
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
|
||||
function installAuthBoot(){
|
||||
|
||||
98
public/core/trial-demo.js
Normal file
@ -0,0 +1,98 @@
|
||||
(()=>{
|
||||
'use strict';
|
||||
if(window.CateriumTrialDemo)return;
|
||||
const KEY='sunTrialDemoV1',BATCH='trial-demo-v1';
|
||||
const $=id=>document.getElementById(id);
|
||||
const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const money=x=>new Intl.NumberFormat('ru-RU',{maximumFractionDigits:2}).format(Number(x)||0)+' ₽';
|
||||
const num=x=>new Intl.NumberFormat('ru-RU',{maximumFractionDigits:3}).format(Number(x)||0);
|
||||
const read=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(key))??fallback}catch(_){return fallback}};
|
||||
const cloud=()=>window.SunCloudV2;
|
||||
const data=()=>window.CateriumDataV1773;
|
||||
const catalog=()=>data()?.catalog.list()||read('sunBoxes',[]);
|
||||
const installed=()=>read(KEY,{})?.version===1;
|
||||
const signedIn=()=>Boolean(cloud()?.getSession?.()?.user&&cloud()?.getWorkspace?.()?.id);
|
||||
const writable=()=>signedIn()&&!cloud()?.isSupportMode?.()&&cloud()?.hasPermission?.('orders.create')&&window.SunSaaSV16?.isWritable?.()!==false;
|
||||
let status=null,statusWorkspace='',pending=null,epoch=0,busy=false;
|
||||
function navigate(id){const label={stock:'Склад',shopping:'Закупки'}[id];const nav=[...document.querySelectorAll('header nav button')].find(b=>String(b.dataset.navLabel||b.textContent||'').trim()===label);if(nav)nav.click();else window.show?.(id);}
|
||||
function message(text){const el=$('ctDemoMessage');if(el)el.textContent=text;}
|
||||
async function refreshStatus(){
|
||||
const c=cloud(),ws=c?.getWorkspace?.()?.id;
|
||||
if(!signedIn()||c?.isSupportMode?.()){status=null;statusWorkspace='';render();return;}
|
||||
if(statusWorkspace===ws||pending)return;
|
||||
const ticket=epoch;pending=ws;
|
||||
try{const result=await c.getClient?.()?.rpc('caterium_trial_demo_status',{p_workspace:ws});
|
||||
if(ticket!==epoch||c.getWorkspace?.()?.id!==ws)return;
|
||||
if(result?.error)throw result.error;
|
||||
status=result?.data||null;statusWorkspace=ws;
|
||||
}catch(_){status=null;}finally{if(ticket===epoch){pending=null;render();}}
|
||||
}
|
||||
function render(){
|
||||
const host=$('new');if(!host)return;
|
||||
const items=catalog().filter(b=>b.demo&&b.ttk),active=installed()&&items.length>0;
|
||||
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;}
|
||||
let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);}
|
||||
const signature=JSON.stringify({active,items:items.map(x=>[x.id,x.name]),can:writable()});
|
||||
if(card.dataset.signature===signature)return;card.dataset.signature=signature;
|
||||
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Демонстрационная база</strong><p>${items.length} боксов с фото и ТТК · учебные остатки и поставщики</p></div><span class="ct-demo-badge">Можно пробовать</span></div>
|
||||
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p>
|
||||
<div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div>
|
||||
<details><summary>Технологические карты боксов</summary><div class="ct-demo-actions"><label>Бокс<select id="ctDemoBox">${items.map(b=>`<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</select></label><button class="outline" data-demo-ttk>Открыть ТТК</button></div></details><p id="ctDemoMessage" role="status" aria-live="polite"></p>`:
|
||||
`<div class="ct-demo-heading"><div><strong>Попробуйте приложение на готовых боксах</strong><p>10 боксов с фото и ТТК, 43 продукта, 5 учебных поставщиков и начальные остатки.</p></div></div><button class="primary" data-demo-install>Загрузить демо</button><p id="ctDemoMessage" role="status" aria-live="polite"></p>`;
|
||||
card.querySelector('[data-demo-install]')?.addEventListener('click',install);
|
||||
card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder);
|
||||
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
|
||||
card.querySelectorAll('[data-demo-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.demoGo)));
|
||||
}
|
||||
async function install(){
|
||||
if(busy)return;busy=true;const btn=$('ctTrialDemo')?.querySelector('[data-demo-install]');if(btn)btn.disabled=true;
|
||||
message('Загружаю демонстрационную базу…');
|
||||
try{await cloud().installTrialDemo();status=null;statusWorkspace='';render();message('Готово. Создайте пробный заказ или выберите боксы самостоятельно.');await refreshStatus();}
|
||||
catch(error){message(error?.message||'Не удалось загрузить демо. Повторите попытку.');}
|
||||
finally{busy=false;if(btn?.isConnected)btn.disabled=false;}
|
||||
}
|
||||
function createOrder(){
|
||||
if(!writable()||!installed())return;
|
||||
const repo=data()?.orders;if(!repo){message('Приложение ещё загружается. Повторите через несколько секунд.');return;}
|
||||
const all=repo.list(),existing=all.find(o=>o.demoBatch===BATCH);
|
||||
if(existing){message(`Пробный заказ №${existing.id} уже создан. Его можно открыть во вкладке «Заказы».`);return;}
|
||||
const spec=read(KEY,{}).scenario?.lines,items=catalog();
|
||||
if(!Array.isArray(spec)||spec.length!==3){message('Сценарий недоступен. Добавьте боксы в заказ самостоятельно.');return;}
|
||||
const lines=spec.map(l=>{const box=items.find(b=>String(b.id)===String(l.id));return box?{id:box.id,name:box.name,qty:l.qty,price:Number(box.price)||0}:null});
|
||||
if(lines.some(l=>!l)){message('Некоторые демо-боксы удалены. Добавьте оставшиеся в заказ самостоятельно.');return;}
|
||||
const total=lines.reduce((s,l)=>s+l.price*l.qty,0),day=new Date();day.setDate(day.getDate()+1);
|
||||
const date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;
|
||||
const id=Math.max(0,...all.map(o=>Number(o.id)||0))+1;
|
||||
repo.replace([...all,{id,event:'Демо: фуршет',date,time:'12:00',contact:'',phone:'',address:'',note:'Учебный заказ для проверки склада и закупки. Автозавершение отключено.',status:'Новый',lines,total,prepayment:0,balance:total,demo:true,demoBatch:BATCH,autoCompletionDisabled:true}],{reason:'trial-demo.order'});
|
||||
window.renderOrders?.();window.sunRefreshStock?.();
|
||||
message(`Заказ №${id} на ${money(total)} создан на завтра. Откройте «Закупки», чтобы увидеть нехватку продуктов.`);
|
||||
}
|
||||
function showTTK(id){
|
||||
const box=catalog().find(b=>String(b.id)===String(id));if(!box?.ttk)return;
|
||||
$('ctDemoTtk')?.remove();const t=box.ttk,dialog=document.createElement('dialog');dialog.id='ctDemoTtk';
|
||||
const changed=JSON.stringify(box.ingredients)!==JSON.stringify(t.rows.map(r=>[r.name,r.gross,r.unit]));
|
||||
dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div>
|
||||
<p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · Цена бокса: <b>${money(box.price)}</b></p>
|
||||
${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''}
|
||||
<div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div>
|
||||
<p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p>
|
||||
<h3>Приготовление и сборка</h3><ol>${t.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p><b>Аллергены:</b> ${t.allergens.map(esc).join(', ')}.</p><p class="hint">${esc(t.note)}</p>
|
||||
<div class="ct-demo-actions"><button class="outline" data-ttk-edit>Изменить состав и цену</button><button class="primary" data-ttk-close>Закрыть</button></div>`;
|
||||
document.body.append(dialog);dialog.querySelectorAll('[data-ttk-close]').forEach(b=>b.onclick=()=>dialog.close());
|
||||
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
|
||||
dialog.addEventListener('close',()=>dialog.remove(),{once:true});dialog.showModal();
|
||||
}
|
||||
function reset(){epoch++;pending=null;status=null;statusWorkspace='';$('ctTrialDemo')?.remove();$('ctDemoTtk')?.remove();}
|
||||
function boot(){
|
||||
const style=document.createElement('style');style.textContent=`#ctTrialDemo{margin:0 0 18px;padding:18px 22px}#ctTrialDemo p{font-size:13px;line-height:1.5;margin:6px 0 12px;max-width:900px}.ct-demo-heading{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.ct-demo-heading strong{font-size:18px}.ct-demo-heading h2{font-size:22px;margin:8px 0}.ct-demo-badge{border:1px solid #c3d4bd;border-radius:18px;padding:5px 10px;font-size:12px;white-space:nowrap}.ct-demo-actions{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:12px 0}.ct-demo-actions label{min-width:0;max-width:100%;flex:1}.ct-demo-actions select{width:100%;font:inherit;padding:9px}.ct-demo-actions button{max-width:100%}#ctTrialDemo details{font-size:13px;margin-top:14px}#ctTrialDemo summary{cursor:pointer}#ctDemoMessage:empty{display:none}#ctDemoTtk{width:min(860px,calc(100vw - 24px));max-height:90dvh;box-sizing:border-box;overflow:auto;border:1px solid #d4c8b0;border-radius:18px;padding:24px;background:var(--card,#fff);color:var(--text,#27241e)}#ctDemoTtk::backdrop{background:#0008}.ct-demo-table{overflow-x:auto}.ct-demo-table table{border-collapse:collapse;min-width:580px;width:100%;font-size:13px}.ct-demo-table td,.ct-demo-table th{text-align:right;padding:9px 7px;border-bottom:1px solid #ddd}.ct-demo-table th:first-child,.ct-demo-table td:first-child{text-align:left}#ctDemoTtk li{margin:8px 0;line-height:1.5}.ct-demo-notice{background:#fff3d6;color:#604918;padding:12px;border-radius:8px}@media(max-width:600px){#ctTrialDemo{padding:14px}.ct-demo-heading{flex-wrap:wrap}.ct-demo-actions>button{flex:1 1 auto}.ct-demo-actions label{flex-basis:100%}#ctDemoTtk{padding:16px}}@media print{#ctTrialDemo{display:none!important}}`;
|
||||
document.head.append(style);
|
||||
window.addEventListener('sun:cloud-tenant-changing',reset);
|
||||
window.addEventListener('sun:cloud-permissions-changed',()=>{render();refreshStatus()});
|
||||
window.addEventListener('sun:cloud-state-applied',()=>{render();refreshStatus()});
|
||||
window.addEventListener('sun:subscription-changed',()=>{statusWorkspace='';render();refreshStatus()});
|
||||
document.addEventListener('click',e=>{if(e.target.closest('header nav button')){render();refreshStatus()}});
|
||||
data()?.subscribe('catalog',render);setTimeout(()=>{render();refreshStatus()},1400);
|
||||
}
|
||||
window.CateriumTrialDemo={render,refreshStatus,showTTK};
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
|
||||
})();
|
||||
1800
public/demo/catalog-v1.json
Normal file
BIN
public/demo/images/berry-dessert.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/demo/images/bruschetta-tomato.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/demo/images/caprese.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/demo/images/cheese-fruit.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
public/demo/images/chicken-sandwich.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/demo/images/meat-assortment.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/demo/images/mushroom-tartlet.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/demo/images/salmon-cream.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/demo/images/turkey-wrap.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
public/demo/images/vegetables-hummus.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
@ -1,7 +1,7 @@
|
||||
const CACHE='sun-catering-pwa-v90-20260918-login-recovery';
|
||||
const VERSION='20260918-login-recovery';
|
||||
const CACHE='sun-catering-pwa-v91-20260918-trial-demo';
|
||||
const VERSION='20260918-trial-demo';
|
||||
const CORE=[
|
||||
'./','./index.html',`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
|
||||
'./','./index.html',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
|
||||
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/data-layer-v1773.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}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.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',
|
||||
@ -9,7 +9,7 @@ const CORE=[
|
||||
'./offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg'
|
||||
];
|
||||
const CRITICAL_FRESH=new Set([
|
||||
'/core/cloud-transport.js',
|
||||
'/core/cloud-transport.js','/core/trial-demo.js',
|
||||
'/core/banquet-menu.js','/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js'
|
||||
]);
|
||||
self.addEventListener('install',event=>{
|
||||
|
||||
84
supabase/migrations/20260918010000_trial_demo_catalog.sql
Normal file
@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
export default defineConfig({
|
||||
testDir:'.',
|
||||
testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs'],
|
||||
testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','trial-demo.spec.mjs'],
|
||||
timeout:30000,
|
||||
use:{baseURL:'http://127.0.0.1:4173'},
|
||||
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
|
||||
|
||||
@ -59,18 +59,50 @@ await q("select public.sun_employee_prepare_v28($1,'test3@example.invalid','View
|
||||
await q("select public.sun_employee_finalize_v28($1,$2,'Viewer','viewer')",[ws,ids[3]]);
|
||||
await actor(ids[2]);const foreign=(await one('select public.sun_create_workspace() as id')).id;
|
||||
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).payload.storage.sunPdfBrandLogoV1,undefined);
|
||||
const demoRow=await one('select * from public.sun_fetch_app_state($1)',[foreign]);
|
||||
const training=demoRow.payload.storage;
|
||||
assert.equal(training.sunBoxes.v.length,10);assert.equal(training.sunStock.v.length,43);assert.equal(training.sunSuppliers.v.length,5);
|
||||
assert.equal(training.sunOrders,undefined);assert.equal(training.sunTrialDemoV1.v.version,1);
|
||||
assert.equal((await one('select public.sun_v17_entity_snapshot($1) as s',[foreign])).s.catalog.length,10);
|
||||
assert.ok(training.sunStockMoves.v.length>0);assert.ok(training.sunStock.v.some(p=>p.qty===0));
|
||||
for(const b of training.sunBoxes.v){assert.ok(b.ttk.ingredientCost>0);assert.equal(b.ttk.outputGrams,Math.round(b.ttk.rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0)));for(const r of b.ttk.rows){assert.ok(r.gross>=r.net);assert.ok(training.sunStock.v.some(p=>p.id===r.productId&&p.name===r.name&&p.unit===r.unit&&p.cost===r.unitCost));}}
|
||||
await q('select public.caterium_install_trial_demo($1)',[foreign]);
|
||||
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,demoRow.revision,'idempotent demo install does not overwrite');
|
||||
const filled=structuredClone(demoRow.payload);delete filled.storage.sunTrialDemoV1;
|
||||
await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,filled,'test-existing-catalog',demoRow.revision]);
|
||||
await denied('select public.caterium_install_trial_demo($1)',[foreign],/уже заполнены/);
|
||||
assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
|
||||
await denied('select * from public.sun_fetch_app_state($1)',[ws],/Access denied/);
|
||||
await denied('select public.caterium_trial_demo_status($1)',[ws],/Access denied/);
|
||||
await denied('select public.caterium_install_trial_demo($1)',[ws],/Administrator membership/);
|
||||
await denied('select * from public.sun_v17_clients_snapshot_v1773($1)',[ws],/Access denied/);
|
||||
await denied('select public.sun_dev_dashboard()',[],/administrator/);
|
||||
await actor(ids[3]);
|
||||
await denied('select public.caterium_install_trial_demo($1)',[ws],/Administrator membership/);
|
||||
assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
|
||||
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,{...payload,storage:{...payload.storage,sunPdfBrandNameV1:'Hijacked'}},'viewer-brand',row.revision],/Нет права/);
|
||||
await denied('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[ws,{...payload,storage:{...payload.storage,sunOrders:{t:'j',v:[{id:2}]}}},'viewer',row.revision],/Нет права/);
|
||||
await actor(ids[0],'aal2');
|
||||
await denied('select public.caterium_install_trial_demo($1)',[foreign],/Administrator membership/);
|
||||
for(const f of ['sun_dev_dashboard','sun_dev_list_companies','sun_dev_list_companies_v1768','sun_dev_list_users','sun_dev_list_errors','sun_dev_list_activity','sun_dev_list_trial_promos'])await q(`select * from public.${f}()`);
|
||||
for(const f of ['sun_dev_workspace_diagnostics','sun_dev_list_workspace_features','sun_dev_support_snapshot'])await q(`select * from public.${f}($1)`,[ws]);
|
||||
await q("select public.sun_dev_create_company('Recovery test company','test3@example.invalid','full',30,'[]','test')");
|
||||
await actor(ids[1]);
|
||||
assert.equal((await one('select public.caterium_trial_demo_status($1) as s',[ws])).s.eligible,false,'Solnce company excluded');
|
||||
await denied('select public.caterium_install_trial_demo($1)',[ws],/Демонстрационная база доступна/);
|
||||
await db.exec('reset role');
|
||||
const devWs=(await one("insert into public.sun_workspaces(name,created_by) values('Own developer test',$1) returning id",[ids[0]])).id;
|
||||
await q("insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions) values($1,$2,'admin',true,public.sun_role_default_permissions('admin'))",[devWs,ids[0]]);
|
||||
await q("insert into public.sun_workspace_subscriptions(workspace_id,plan_id,status,current_period_end) values($1,'full','active',now()+interval '30 days')",[devWs]);
|
||||
const preserved={format:'sun-cloud-v2',version:2,storage:{sunCompanyProfileV1:{t:'j',v:{name:'Own company'}},sunClientLoyaltyV1:{t:'j',v:{fixture:'preserve me'}}}};
|
||||
await q("insert into public.sun_app_state(workspace_id,payload,client_id) values($1,$2,'fixture')",[devWs,preserved]);
|
||||
await actor(ids[0]);
|
||||
await q('select public.caterium_install_trial_demo($1)',[devWs]);
|
||||
const seeded=(await one('select * from public.sun_fetch_app_state($1)',[devWs])).payload.storage;
|
||||
assert.deepEqual(seeded.sunClientLoyaltyV1,preserved.storage.sunClientLoyaltyV1);assert.deepEqual(seeded.sunCompanyProfileV1,preserved.storage.sunCompanyProfileV1);
|
||||
assert.equal(seeded.sunBoxes.v.length,10,'developer own company works without developer-panel MFA');
|
||||
await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_demo($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_demo_seed_v1()',[],/permission denied/);
|
||||
console.log('PASS demo: trial auto seed, 10 TTKs, stock links, idempotency, populated catalog guard, Solnce exclusion, own developer account and preserved clients');
|
||||
console.log('PASS smoke: owner onboarding, save/read, optimistic conflict, normalized catalog/orders/clients, backups, employees, cross-tenant isolation, viewer write denial, AAL2, developer RPCs');
|
||||
await db.close();
|
||||
}
|
||||
|
||||
@ -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('v90-20260918-login-recovery')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
|
||||
check(index.includes('20260918-login-recovery')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
|
||||
check(sw.includes('v91-20260918-trial-demo')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
|
||||
check(index.includes('20260918-trial-demo')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
|
||||
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,7 +39,7 @@ 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('v90-20260918-login-recovery'),'release manifest points to current PWA cache');
|
||||
check(String(releaseManifest.pwaCache||'').includes('v91-20260918-trial-demo'),'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','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
|
||||
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.3'")&&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');
|
||||
@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace
|
||||
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.3','package version is v17.7.3');
|
||||
check(index.includes('20260918-login-recovery'),'index cache bust is v17.7.3');
|
||||
check(sw.includes('v90-20260918-login-recovery')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
|
||||
check(index.includes('20260918-trial-demo'),'index cache bust is v17.7.3');
|
||||
check(sw.includes('v91-20260918-trial-demo')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
|
||||
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.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.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');
|
||||
|
||||
@ -28,8 +28,8 @@ 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('20260918-login-recovery')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.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 for login refresh');
|
||||
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-login-recovery')||!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('20260918-trial-demo')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.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 for login refresh');
|
||||
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-trial-demo')||!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');
|
||||
|
||||
69
tests/trial-demo.spec.mjs
Normal file
@ -0,0 +1,69 @@
|
||||
import {test,expect} from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import {demo} from '../ops/demo/trial-demo-data.mjs';
|
||||
|
||||
async function ready(page){
|
||||
page.on('dialog',d=>d.accept());
|
||||
await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort());
|
||||
await page.goto('/index.html',{waitUntil:'domcontentloaded'});
|
||||
await page.waitForFunction(()=>window.CateriumDataV1773&&window.CateriumTrialDemo&&window.SunAdminRBACV3);
|
||||
await page.evaluate(d=>{
|
||||
window.SunCloudV2={getSession:()=>({user:{id:'test'}}),getWorkspace:()=>({id:'test',role:'admin',permissions:{}}),getClient:()=>({rpc:async(name)=>({data:name==='sun_subscription_snapshot'?{access_mode:'full',status:'trialing',plan_name:'Полный',features:Object.fromEntries(['orders','shopping','stock','catalog_view','catalog_edit','suppliers','settings','clients','calendar','print'].map(k=>[k,true]))}:name==='caterium_trial_demo_status'?{eligible:true,installed:true,empty:false,canInstall:false}:null})}),status:()=>({connected:true,signedIn:true,membershipsLoaded:true}),hasPermission:()=>true};
|
||||
// Hydrate AFTER stock UI boot: the real cloud path must update its closure.
|
||||
for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario}}))localStorage.setItem(key,JSON.stringify(value));
|
||||
boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render();
|
||||
},demo);
|
||||
await expect(page.locator('#ctTrialDemo')).toBeVisible();
|
||||
}
|
||||
test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{
|
||||
await ready(page);
|
||||
await page.locator('#ctTrialDemo summary').click();await page.locator('[data-demo-ttk]').click();
|
||||
await expect(page.locator('#ctDemoTtk')).toBeVisible();await expect(page.locator('#ctDemoTtk')).toContainText('840 г');await expect(page.locator('#ctDemoTtk')).toContainText('Брутто');
|
||||
expect(await page.locator('#ctDemoTtk').evaluate(el=>el.getBoundingClientRect().width<=innerWidth)).toBe(true);
|
||||
await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click();
|
||||
await page.locator('[data-demo-order]').click();await expect(page.locator('#ctDemoMessage')).toContainText('9 700 ₽');
|
||||
await page.locator('[data-demo-order]').click();expect(await page.evaluate(()=>orders.length)).toBe(1);
|
||||
expect(await page.evaluate(()=>orders[0].autoCompletionDisabled)).toBe(true);
|
||||
await page.locator('[data-demo-go="shopping"]').click();
|
||||
await expect(page.locator('[data-shop-key="багет|кг"]')).toContainText('0,96');
|
||||
await expect(page.locator('[data-shop-key="багет|кг"]')).toContainText('230');
|
||||
await page.getByRole('button',{name:'Склад',exact:true}).first().click();
|
||||
await page.evaluate(()=>window.stockMovement('demo-v1-stock-baguette','in'));
|
||||
await page.locator('#stockMoveQty').fill('2');await page.locator('#stockSaveMove').click();
|
||||
expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-v1-stock-baguette').qty)).toBe(2);
|
||||
expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-v1-stock-baguette').cost)).toBe(240);
|
||||
await page.getByRole('button',{name:'Закупки',exact:true}).first().click();await expect(page.locator('[data-shop-key="багет|кг"]')).toHaveCount(0);
|
||||
await page.evaluate(()=>window.writeOffOrderStock(orders[0].id));
|
||||
const written=await page.evaluate(()=>({qty:JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-v1-stock-baguette').qty,moves:JSON.parse(localStorage.sunStockMoves).length,done:orders[0].stockWrittenOff}));
|
||||
expect(written.qty).toBeCloseTo(1.04,3);expect(written.done).toBe(true);expect(written.moves).toBeGreaterThan(5);
|
||||
await page.evaluate(()=>window.writeOffOrderStock(orders[0].id));expect(await page.evaluate(()=>JSON.parse(localStorage.sunStockMoves).length)).toBe(written.moves);
|
||||
});
|
||||
test('demo photo assets load and company changes remove the trial guide',async({page})=>{
|
||||
await ready(page);
|
||||
expect(await page.evaluate(async d=>{const images=await Promise.all(d.boxes.map(b=>new Promise(resolve=>{const i=new Image();i.onload=()=>resolve(i.naturalWidth>500);i.onerror=()=>resolve(false);i.src=b.photo})));return images.every(Boolean)},demo)).toBe(true);
|
||||
await page.evaluate(()=>{window.dispatchEvent(new Event('sun:cloud-tenant-changing'));window.SunCloudV2.getSession=()=>null;localStorage.removeItem('sunTrialDemoV1');window.CateriumTrialDemo.render()});
|
||||
await expect(page.locator('#ctTrialDemo')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('owner onboarding preserves server demo when saving the company profile',async({page})=>{
|
||||
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));await page.goto('/index.html');
|
||||
await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'});
|
||||
const runtime=fs.readFileSync('public/app-runtime.js','utf8');let source=runtime.slice(runtime.indexOf('/* ===== MODULE: cloud-sync-v2.js'),runtime.indexOf('/* ===== MODULE: admin-rbac-v3.js'));
|
||||
source=source.replace('async function boot(){','async function boot(){return;').replace('window.SunCloudV2={',`window.SunCloudV2={testInit:c=>{client=c;session={user:{id:'test-user'}};config.tenantStorageReady=true;config.localWorkspaceId='';config.workspaceId=''},`);
|
||||
await page.addScriptTag({content:'var boxes=[],orders=[];async function compactSunBoxesForStorage(x){return x};'+source});
|
||||
const result=await page.evaluate(async d=>{
|
||||
let created=false,saved=null;const payload={format:'sun-cloud-v2',version:2,storage:{sunBoxes:{t:'j',v:d.boxes},sunStock:{t:'j',v:d.stock},sunSuppliers:{t:'j',v:d.suppliers},sunTrialDemoV1:{t:'j',v:{version:1,scenario:d.scenario}},sunCompanyProfileV1:{t:'j',v:{email:'owner@example.invalid',name:''}}}};
|
||||
const row={payload,revision:1,updated_at:new Date().toISOString()};
|
||||
window.SunCloudV2.testInit({rpc:async(name,args)=>{
|
||||
if(name==='sun_my_workspaces')return {data:created?[{id:'company',name:'Test',role:'admin',is_active:true}]:[]};
|
||||
if(name==='sun_create_workspace'){created=true;return {data:'company'}}
|
||||
if(name==='sun_fetch_app_state')return {data:[row]};
|
||||
if(name==='sun_save_app_state_v17'){saved=structuredClone(args.p_payload);return {data:[{...row,payload:saved,revision:2}]}}
|
||||
return {data:null};
|
||||
},channel:()=>({on(){return this},subscribe(){return this}})});
|
||||
await window.SunCloudV2.createTrialWorkspace('My trial company');
|
||||
return {saved,local:JSON.parse(localStorage.sunBoxes),workspace:window.SunCloudV2.getWorkspace()};
|
||||
},demo);
|
||||
expect(result.saved.storage.sunBoxes.v).toHaveLength(10);expect(result.local).toHaveLength(10);
|
||||
expect(result.saved.storage.sunStock.v).toEqual(demo.stock);expect(result.saved.storage.sunCompanyProfileV1.v).toMatchObject({name:'My trial company',email:'owner@example.invalid'});
|
||||
});
|
||||