diff --git a/docs/release-manifest.json b/docs/release-manifest.json index c2e374e..d2ec909 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -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 + } } diff --git a/docs/releases/2026-09-18-TRIAL-DEMO.md b/docs/releases/2026-09-18-TRIAL-DEMO.md new file mode 100644 index 0000000..eacdcd0 --- /dev/null +++ b/docs/releases/2026-09-18-TRIAL-DEMO.md @@ -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. diff --git a/ops/demo/build-trial-demo.mjs b/ops/demo/build-trial-demo.mjs new file mode 100644 index 0000000..e8cf497 --- /dev/null +++ b/ops/demo/build-trial-demo.mjs @@ -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.`); diff --git a/ops/demo/image-prompts.json b/ops/demo/image-prompts.json new file mode 100644 index 0000000..75ce7f8 --- /dev/null +++ b/ops/demo/image-prompts.json @@ -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." + } + ] +} diff --git a/ops/demo/trial-demo-data.mjs b/ops/demo/trial-demo-data.mjs new file mode 100644 index 0000000..0e61d0c --- /dev/null +++ b/ops/demo/trial-demo-data.mjs @@ -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}]}}; diff --git a/ops/demo/trial-demo.sql b/ops/demo/trial-demo.sql new file mode 100644 index 0000000..59044f7 --- /dev/null +++ b/ops/demo/trial-demo.sql @@ -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; diff --git a/package.json b/package.json index 3b76394..39722a2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/app-runtime.js b/public/app-runtime.js index b398a5f..ffed390 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -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}), diff --git a/public/core/cloud-transport.js b/public/core/cloud-transport.js index 0acf8ca..9e4161c 100644 --- a/public/core/cloud-transport.js +++ b/public/core/cloud-transport.js @@ -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={}){ diff --git a/public/core/performance.js b/public/core/performance.js index 52aa000..741e0c9 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -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(){ diff --git a/public/core/trial-demo.js b/public/core/trial-demo.js new file mode 100644 index 0000000..0f92574 --- /dev/null +++ b/public/core/trial-demo.js @@ -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?`
Демонстрационная база

${items.length} боксов с фото и ТТК · учебные остатки и поставщики

Можно пробовать
+

Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.

+
+
Технологические карты боксов

`: + `
Попробуйте приложение на готовых боксах

10 боксов с фото и ТТК, 43 продукта, 5 учебных поставщиков и начальные остатки.

`; + 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=`
${esc(t.number)} · ${esc(t.basis)}

${esc(box.name)}

+

${changed?'Исходный выход':'Выход'}: ${num(t.outputGrams)} г${t.pieces?` · ${num(t.pieces)} шт.`:''} · Цена бокса: ${money(box.price)}

+ ${changed?'

Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.

':''} +
${t.rows.map(r=>``).join('')}
Продукт / упаковкаЕд.БруттоНеттоЦена за ед.Сумма
${esc(r.name)}${esc(r.unit)}${num(r.gross)}${num(r.net)}${money(r.unitCost)}${money(r.gross*r.unitCost)}
+

Продукты и упаковка: ${money(t.ingredientCost)}

Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.

+

Приготовление и сборка

    ${t.steps.map(s=>`
  1. ${esc(s)}
  2. `).join('')}

Аллергены: ${t.allergens.map(esc).join(', ')}.

${esc(t.note)}

+
`; + 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(); +})(); diff --git a/public/demo/catalog-v1.json b/public/demo/catalog-v1.json new file mode 100644 index 0000000..4a3cdc2 --- /dev/null +++ b/public/demo/catalog-v1.json @@ -0,0 +1,1800 @@ +{ + "version": 1, + "batch": "trial-demo-v1", + "boxes": [ + { + "id": "demo-v1-bruschetta-tomato", + "name": "Брускетты с томатами и базиликом", + "price": 2200, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 24, + "weight": "840 г", + "photo": "demo/images/bruschetta-tomato.png", + "composition": [ + "24 брускетты с томатами, базиликом и оливковым маслом" + ], + "ingredients": [ + [ + "Багет", + 0.48, + "кг" + ], + [ + "Томаты", + 0.33, + "кг" + ], + [ + "Базилик", + 0.026, + "кг" + ], + [ + "Масло оливковое", + 0.036, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-01", + "basis": "На 1 готовый бокс", + "outputGrams": 840, + "pieces": 24, + "rows": [ + { + "productId": "demo-v1-stock-baguette", + "name": "Багет", + "unit": "кг", + "gross": 0.48, + "net": 0.48, + "unitCost": 240 + }, + { + "productId": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "gross": 0.33, + "net": 0.3, + "unitCost": 260 + }, + { + "productId": "demo-v1-stock-basil", + "name": "Базилик", + "unit": "кг", + "gross": 0.026, + "net": 0.024, + "unitCost": 1500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.036, + "net": 0.036, + "unitCost": 1100 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Нарезать багет на 24 одинаковых ломтика и подсушить.", + "Подготовить томаты и базилик, смешать с маслом.", + "Распределить начинку по ломтикам, уложить в коробку." + ], + "allergens": [ + "Глютен" + ], + "ingredientCost": 344.6, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-salmon-cream", + "name": "Канапе с лососем и сливочным сыром", + "price": 3200, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 16, + "weight": "720 г", + "photo": "demo/images/salmon-cream.png", + "composition": [ + "16 канапе на ржаном хлебе с лососем, сливочным сыром и огурцом" + ], + "ingredients": [ + [ + "Хлеб ржаной", + 0.24, + "кг" + ], + [ + "Лосось слабосолёный", + 0.24, + "кг" + ], + [ + "Сыр сливочный", + 0.16, + "кг" + ], + [ + "Огурцы", + 0.084, + "кг" + ], + [ + "Укроп", + 0.01, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-02", + "basis": "На 1 готовый бокс", + "outputGrams": 720, + "pieces": 16, + "rows": [ + { + "productId": "demo-v1-stock-rye", + "name": "Хлеб ржаной", + "unit": "кг", + "gross": 0.24, + "net": 0.24, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-salmon", + "name": "Лосось слабосолёный", + "unit": "кг", + "gross": 0.24, + "net": 0.24, + "unitCost": 2400 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.16, + "net": 0.16, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "gross": 0.084, + "net": 0.072, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-dill", + "name": "Укроп", + "unit": "кг", + "gross": 0.01, + "net": 0.008, + "unitCost": 700 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Вырезать из ржаного хлеба 16 основ.", + "Нанести сливочный сыр, добавить ломтики огурца и лосося.", + "Украсить укропом и уложить в коробку." + ], + "allergens": [ + "Рыба", + "Молоко", + "Глютен" + ], + "ingredientCost": 855.28, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-chicken-sandwich", + "name": "Мини-сэндвичи с курицей", + "price": 2400, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 12, + "weight": "840 г", + "photo": "demo/images/chicken-sandwich.png", + "composition": [ + "12 мини-сэндвичей с готовым куриным филе, томатами, салатом и сливочным сыром" + ], + "ingredients": [ + [ + "Хлеб тостовый", + 0.36, + "кг" + ], + [ + "Филе курицы готовое", + 0.24, + "кг" + ], + [ + "Сыр сливочный", + 0.12, + "кг" + ], + [ + "Томаты", + 0.092, + "кг" + ], + [ + "Салат листовой", + 0.045, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-03", + "basis": "На 1 готовый бокс", + "outputGrams": 840, + "pieces": 12, + "rows": [ + { + "productId": "demo-v1-stock-toast", + "name": "Хлеб тостовый", + "unit": "кг", + "gross": 0.36, + "net": 0.36, + "unitCost": 240 + }, + { + "productId": "demo-v1-stock-chicken", + "name": "Филе курицы готовое", + "unit": "кг", + "gross": 0.24, + "net": 0.24, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.12, + "net": 0.12, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "gross": 0.092, + "net": 0.084, + "unitCost": 260 + }, + { + "productId": "demo-v1-stock-lettuce", + "name": "Салат листовой", + "unit": "кг", + "gross": 0.045, + "net": 0.036, + "unitCost": 700 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Нарезать готовое куриное филе, подготовить овощи и салат.", + "Собрать сэндвичи из тостового хлеба, сыра, курицы и овощей.", + "Нарезать на 12 одинаковых мини-сэндвичей и упаковать." + ], + "allergens": [ + "Глютен", + "Молоко" + ], + "ingredientCost": 512.82, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-caprese", + "name": "Канапе «Капрезе»", + "price": 2100, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 20, + "weight": "700 г", + "photo": "demo/images/caprese.png", + "composition": [ + "20 шпажек с моцареллой, черри и базиликом" + ], + "ingredients": [ + [ + "Моцарелла мини", + 0.36, + "кг" + ], + [ + "Томаты черри", + 0.308, + "кг" + ], + [ + "Базилик", + 0.022, + "кг" + ], + [ + "Масло оливковое", + 0.04, + "кг" + ], + [ + "Шпажка бамбуковая", + 20, + "шт." + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-04", + "basis": "На 1 готовый бокс", + "outputGrams": 700, + "pieces": 20, + "rows": [ + { + "productId": "demo-v1-stock-mozzarella", + "name": "Моцарелла мини", + "unit": "кг", + "gross": 0.36, + "net": 0.36, + "unitCost": 950 + }, + { + "productId": "demo-v1-stock-cherry", + "name": "Томаты черри", + "unit": "кг", + "gross": 0.308, + "net": 0.28, + "unitCost": 450 + }, + { + "productId": "demo-v1-stock-basil", + "name": "Базилик", + "unit": "кг", + "gross": 0.022, + "net": 0.02, + "unitCost": 1500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.04, + "net": 0.04, + "unitCost": 1100 + }, + { + "productId": "demo-v1-stock-skewer", + "name": "Шпажка бамбуковая", + "unit": "шт.", + "gross": 20, + "net": 20, + "unitCost": 2 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Подготовить черри и базилик, обсушить моцареллу.", + "Собрать 20 шпажек, распределив ингредиенты поровну.", + "Добавить оливковое масло, уложить в коробку." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 662.6, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-mushroom-tartlet", + "name": "Тарталетки с грибами и сыром", + "price": 2300, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 20, + "weight": "800 г", + "photo": "demo/images/mushroom-tartlet.png", + "composition": [ + "20 тарталеток с шампиньонами, сливками и сыром" + ], + "ingredients": [ + [ + "Тарталетки готовые", + 0.2, + "кг" + ], + [ + "Шампиньоны", + 0.44, + "кг" + ], + [ + "Сливки", + 0.1, + "кг" + ], + [ + "Сыр гауда", + 0.12, + "кг" + ], + [ + "Лук репчатый", + 0.088, + "кг" + ], + [ + "Масло оливковое", + 0.02, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-05", + "basis": "На 1 готовый бокс", + "outputGrams": 800, + "pieces": 20, + "rows": [ + { + "productId": "demo-v1-stock-tartlet", + "name": "Тарталетки готовые", + "unit": "кг", + "gross": 0.2, + "net": 0.2, + "unitCost": 750 + }, + { + "productId": "demo-v1-stock-mushroom", + "name": "Шампиньоны", + "unit": "кг", + "gross": 0.44, + "net": 0.3, + "unitCost": 340 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.1, + "net": 0.1, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.12, + "net": 0.12, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-onion", + "name": "Лук репчатый", + "unit": "кг", + "gross": 0.088, + "net": 0.06, + "unitCost": 80 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 1100 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.", + "Добавить сливки и распределить начинку по 20 тарталеткам.", + "Добавить сыр, запечь до расплавления и упаковать после охлаждения." + ], + "allergens": [ + "Глютен", + "Молоко" + ], + "ingredientCost": 551.64, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-turkey-wrap", + "name": "Роллы из тортильи с индейкой", + "price": 2700, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 25, + "weight": "1000 г", + "photo": "demo/images/turkey-wrap.png", + "composition": [ + "25 мини-роллов с ветчиной из индейки, сливочным сыром, перцем и салатом" + ], + "ingredients": [ + [ + "Тортилья", + 0.32, + "кг" + ], + [ + "Ветчина из индейки", + 0.32, + "кг" + ], + [ + "Сыр сливочный", + 0.18, + "кг" + ], + [ + "Салат листовой", + 0.075, + "кг" + ], + [ + "Перец сладкий", + 0.15, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-06", + "basis": "На 1 готовый бокс", + "outputGrams": 1000, + "pieces": 25, + "rows": [ + { + "productId": "demo-v1-stock-tortilla", + "name": "Тортилья", + "unit": "кг", + "gross": 0.32, + "net": 0.32, + "unitCost": 450 + }, + { + "productId": "demo-v1-stock-turkey", + "name": "Ветчина из индейки", + "unit": "кг", + "gross": 0.32, + "net": 0.32, + "unitCost": 1000 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.18, + "net": 0.18, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-lettuce", + "name": "Салат листовой", + "unit": "кг", + "gross": 0.075, + "net": 0.06, + "unitCost": 700 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.15, + "net": 0.12, + "unitCost": 380 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Подготовить салат и перец, нарезать ветчину.", + "Нанести сыр на тортильи, распределить начинку и свернуть плотные рулеты.", + "Нарезать на 25 порций и уложить срезом вверх." + ], + "allergens": [ + "Глютен", + "Молоко" + ], + "ingredientCost": 791.5, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-cheese-fruit", + "name": "Сырный бокс с виноградом и орехами", + "price": 2900, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 0, + "weight": "800 г", + "photo": "demo/images/cheese-fruit.png", + "composition": [ + "Бри, гауда и сыр с голубой плесенью", + "Виноград, грецкий орех, мёд и крекер" + ], + "ingredients": [ + [ + "Сыр бри", + 0.2, + "кг" + ], + [ + "Сыр гауда", + 0.18, + "кг" + ], + [ + "Сыр с голубой плесенью", + 0.12, + "кг" + ], + [ + "Виноград", + 0.176, + "кг" + ], + [ + "Орех грецкий очищенный", + 0.05, + "кг" + ], + [ + "Мёд", + 0.04, + "кг" + ], + [ + "Крекер", + 0.05, + "кг" + ], + [ + "Соусник", + 1, + "шт." + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-07", + "basis": "На 1 готовый бокс", + "outputGrams": 800, + "pieces": 0, + "rows": [ + { + "productId": "demo-v1-stock-brie", + "name": "Сыр бри", + "unit": "кг", + "gross": 0.2, + "net": 0.2, + "unitCost": 1600 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.18, + "net": 0.18, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-blue", + "name": "Сыр с голубой плесенью", + "unit": "кг", + "gross": 0.12, + "net": 0.12, + "unitCost": 1700 + }, + { + "productId": "demo-v1-stock-grape", + "name": "Виноград", + "unit": "кг", + "gross": 0.176, + "net": 0.16, + "unitCost": 420 + }, + { + "productId": "demo-v1-stock-walnut", + "name": "Орех грецкий очищенный", + "unit": "кг", + "gross": 0.05, + "net": 0.05, + "unitCost": 1000 + }, + { + "productId": "demo-v1-stock-honey", + "name": "Мёд", + "unit": "кг", + "gross": 0.04, + "net": 0.04, + "unitCost": 800 + }, + { + "productId": "demo-v1-stock-cracker", + "name": "Крекер", + "unit": "кг", + "gross": 0.05, + "net": 0.05, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-sauce-cup", + "name": "Соусник", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 7 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Нарезать сыры порционными кусочками.", + "Подготовить виноград, переложить мёд в соусник.", + "Разложить сыры, виноград, орехи и крекер отдельными секциями." + ], + "allergens": [ + "Молоко", + "Орехи", + "Глютен" + ], + "ingredientCost": 938.92, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-meat-assortment", + "name": "Мясное ассорти с корнишонами", + "price": 3200, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 0, + "weight": "700 г", + "photo": "demo/images/meat-assortment.png", + "composition": [ + "Салями, готовый ростбиф и ветчина из индейки", + "Корнишоны, маслины и горчица" + ], + "ingredients": [ + [ + "Салями", + 0.18, + "кг" + ], + [ + "Ростбиф готовый", + 0.22, + "кг" + ], + [ + "Ветчина из индейки", + 0.15, + "кг" + ], + [ + "Корнишоны", + 0.09, + "кг" + ], + [ + "Маслины без косточек", + 0.045, + "кг" + ], + [ + "Горчица", + 0.03, + "кг" + ], + [ + "Соусник", + 1, + "шт." + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-08", + "basis": "На 1 готовый бокс", + "outputGrams": 700, + "pieces": 0, + "rows": [ + { + "productId": "demo-v1-stock-salami", + "name": "Салями", + "unit": "кг", + "gross": 0.18, + "net": 0.18, + "unitCost": 1400 + }, + { + "productId": "demo-v1-stock-beef", + "name": "Ростбиф готовый", + "unit": "кг", + "gross": 0.22, + "net": 0.22, + "unitCost": 2000 + }, + { + "productId": "demo-v1-stock-turkey", + "name": "Ветчина из индейки", + "unit": "кг", + "gross": 0.15, + "net": 0.15, + "unitCost": 1000 + }, + { + "productId": "demo-v1-stock-pickle", + "name": "Корнишоны", + "unit": "кг", + "gross": 0.09, + "net": 0.08, + "unitCost": 400 + }, + { + "productId": "demo-v1-stock-olive", + "name": "Маслины без косточек", + "unit": "кг", + "gross": 0.045, + "net": 0.04, + "unitCost": 650 + }, + { + "productId": "demo-v1-stock-mustard", + "name": "Горчица", + "unit": "кг", + "gross": 0.03, + "net": 0.03, + "unitCost": 350 + }, + { + "productId": "demo-v1-stock-sauce-cup", + "name": "Соусник", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 7 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Нарезать готовые мясные продукты тонкими ломтиками.", + "Обсушить корнишоны и маслины, переложить горчицу в соусник.", + "Разложить ассорти секциями, добавить гарниры." + ], + "allergens": [ + "Горчица" + ], + "ingredientCost": 989.75, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-vegetables-hummus", + "name": "Овощной бокс с хумусом", + "price": 2100, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 0, + "weight": "1000 г", + "photo": "demo/images/vegetables-hummus.png", + "composition": [ + "Морковь, огурцы, сладкий перец и черри", + "Хумус в отдельном соуснике" + ], + "ingredients": [ + [ + "Морковь", + 0.3, + "кг" + ], + [ + "Огурцы", + 0.275, + "кг" + ], + [ + "Перец сладкий", + 0.25, + "кг" + ], + [ + "Томаты черри", + 0.165, + "кг" + ], + [ + "Хумус готовый", + 0.15, + "кг" + ], + [ + "Соусник", + 1, + "шт." + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-09", + "basis": "На 1 готовый бокс", + "outputGrams": 1000, + "pieces": 0, + "rows": [ + { + "productId": "demo-v1-stock-carrot", + "name": "Морковь", + "unit": "кг", + "gross": 0.3, + "net": 0.25, + "unitCost": 90 + }, + { + "productId": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "gross": 0.275, + "net": 0.25, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.25, + "net": 0.2, + "unitCost": 380 + }, + { + "productId": "demo-v1-stock-cherry", + "name": "Томаты черри", + "unit": "кг", + "gross": 0.165, + "net": 0.15, + "unitCost": 450 + }, + { + "productId": "demo-v1-stock-hummus", + "name": "Хумус готовый", + "unit": "кг", + "gross": 0.15, + "net": 0.15, + "unitCost": 600 + }, + { + "productId": "demo-v1-stock-sauce-cup", + "name": "Соусник", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 7 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Вымыть и подготовить овощи; очистить морковь и перец.", + "Нарезать овощи палочками, черри оставить целыми.", + "Переложить хумус в соусник и разложить овощи вокруг него." + ], + "allergens": [ + "Кунжут в составе хумуса" + ], + "ingredientCost": 418.75, + "note": "Учебные нормы и цены для знакомства с приложением." + } + }, + { + "id": "demo-v1-berry-dessert", + "name": "Ягодные мини-десерты", + "price": 2800, + "category": 0, + "catalogSection": "Демонстрационные боксы", + "pieces": 16, + "weight": "960 г", + "photo": "demo/images/berry-dessert.png", + "composition": [ + "16 десертов со сливочным кремом, бисквитом, клубникой и голубикой" + ], + "ingredients": [ + [ + "Крошка бисквитная", + 0.16, + "кг" + ], + [ + "Сыр сливочный", + 0.32, + "кг" + ], + [ + "Сливки", + 0.16, + "кг" + ], + [ + "Клубника", + 0.176, + "кг" + ], + [ + "Голубика", + 0.084, + "кг" + ], + [ + "Пудра сахарная", + 0.08, + "кг" + ], + [ + "Стаканчик десертный", + 16, + "шт." + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ] + ], + "demo": true, + "ttk": { + "number": "ДЕМО-10", + "basis": "На 1 готовый бокс", + "outputGrams": 960, + "pieces": 16, + "rows": [ + { + "productId": "demo-v1-stock-biscuit", + "name": "Крошка бисквитная", + "unit": "кг", + "gross": 0.16, + "net": 0.16, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.32, + "net": 0.32, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.16, + "net": 0.16, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-strawberry", + "name": "Клубника", + "unit": "кг", + "gross": 0.176, + "net": 0.16, + "unitCost": 750 + }, + { + "productId": "demo-v1-stock-blueberry", + "name": "Голубика", + "unit": "кг", + "gross": 0.084, + "net": 0.08, + "unitCost": 1300 + }, + { + "productId": "demo-v1-stock-sugar", + "name": "Пудра сахарная", + "unit": "кг", + "gross": 0.08, + "net": 0.08, + "unitCost": 160 + }, + { + "productId": "demo-v1-stock-cup", + "name": "Стаканчик десертный", + "unit": "шт.", + "gross": 16, + "net": 16, + "unitCost": 9 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + } + ], + "steps": [ + "Подготовить ягоды, разделить готовый бисквит на крошку.", + "Смешать сливочный сыр, сливки и сахарную пудру в крем.", + "Собрать 16 стаканчиков слоями: бисквит, крем, ягоды." + ], + "allergens": [ + "Молоко", + "Глютен", + "Яйцо в составе бисквита" + ], + "ingredientCost": 895, + "note": "Учебные нормы и цены для знакомства с приложением." + } + } + ], + "stock": [ + { + "id": "demo-v1-stock-baguette", + "name": "Багет", + "unit": "кг", + "cost": 240, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "cost": 260, + "qty": 0.19, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-basil", + "name": "Базилик", + "unit": "кг", + "cost": 1500, + "qty": 0.096, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "cost": 1100, + "qty": 0.067, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-rye", + "name": "Хлеб ржаной", + "unit": "кг", + "cost": 220, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-salmon", + "name": "Лосось слабосолёный", + "unit": "кг", + "cost": 2400, + "qty": 0.108, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "cost": 850, + "qty": 1.56, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "cost": 220, + "qty": 0.251, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-dill", + "name": "Укроп", + "unit": "кг", + "cost": 700, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-toast", + "name": "Хлеб тостовый", + "unit": "кг", + "cost": 240, + "qty": 0.162, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-chicken", + "name": "Филе курицы готовое", + "unit": "кг", + "cost": 850, + "qty": 0.48, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-lettuce", + "name": "Салат листовой", + "unit": "кг", + "cost": 700, + "qty": 0.084, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-mozzarella", + "name": "Моцарелла мини", + "unit": "кг", + "cost": 950, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-cherry", + "name": "Томаты черри", + "unit": "кг", + "cost": 450, + "qty": 0.213, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-tartlet", + "name": "Тарталетки готовые", + "unit": "кг", + "cost": 750, + "qty": 0.4, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-mushroom", + "name": "Шампиньоны", + "unit": "кг", + "cost": 340, + "qty": 0.308, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "cost": 500, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "cost": 900, + "qty": 0.135, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-onion", + "name": "Лук репчатый", + "unit": "кг", + "cost": 80, + "qty": 0.176, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-tortilla", + "name": "Тортилья", + "unit": "кг", + "cost": 450, + "qty": 0.224, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-turkey", + "name": "Ветчина из индейки", + "unit": "кг", + "cost": 1000, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "cost": 380, + "qty": 0.18, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-brie", + "name": "Сыр бри", + "unit": "кг", + "cost": 1600, + "qty": 0.4, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-blue", + "name": "Сыр с голубой плесенью", + "unit": "кг", + "cost": 1700, + "qty": 0.084, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-grape", + "name": "Виноград", + "unit": "кг", + "cost": 420, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-walnut", + "name": "Орех грецкий очищенный", + "unit": "кг", + "cost": 1000, + "qty": 0.023, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-honey", + "name": "Мёд", + "unit": "кг", + "cost": 800, + "qty": 0.08, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-cracker", + "name": "Крекер", + "unit": "кг", + "cost": 500, + "qty": 0.035, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-salami", + "name": "Салями", + "unit": "кг", + "cost": 1400, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-beef", + "name": "Ростбиф готовый", + "unit": "кг", + "cost": 2000, + "qty": 0.099, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-pickle", + "name": "Корнишоны", + "unit": "кг", + "cost": 400, + "qty": 0.18, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-olive", + "name": "Маслины без косточек", + "unit": "кг", + "cost": 650, + "qty": 0.032, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-mustard", + "name": "Горчица", + "unit": "кг", + "cost": 350, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-carrot", + "name": "Морковь", + "unit": "кг", + "cost": 90, + "qty": 0.135, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-hummus", + "name": "Хумус готовый", + "unit": "кг", + "cost": 600, + "qty": 0.3, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-biscuit", + "name": "Крошка бисквитная", + "unit": "кг", + "cost": 500, + "qty": 0.112, + "min": 0.1, + "supplierId": "demo-v1-supplier-bakery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-strawberry", + "name": "Клубника", + "unit": "кг", + "cost": 750, + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-blueberry", + "name": "Голубика", + "unit": "кг", + "cost": 1300, + "qty": 0.038, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-sugar", + "name": "Пудра сахарная", + "unit": "кг", + "cost": 160, + "qty": 0.16, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "cost": 65, + "qty": 7, + "min": 2, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-skewer", + "name": "Шпажка бамбуковая", + "unit": "шт.", + "cost": 2, + "qty": 0, + "min": 2, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-cup", + "name": "Стаканчик десертный", + "unit": "шт.", + "cost": 9, + "qty": 7, + "min": 2, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-v1-stock-sauce-cup", + "name": "Соусник", + "unit": "шт.", + "cost": 7, + "qty": 6, + "min": 2, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + } + ], + "suppliers": [ + { + "id": "demo-v1-supplier-fresh", + "name": "Демо: овощи и молочные продукты", + "active": true, + "category": "Учебный поставщик", + "contact": "", + "phone": "", + "email": "", + "address": "", + "deliveryDays": "По договорённости", + "minOrder": 0, + "note": "Вымышленный поставщик для знакомства с приложением." + }, + { + "id": "demo-v1-supplier-protein", + "name": "Демо: мясо и рыба", + "active": true, + "category": "Учебный поставщик", + "contact": "", + "phone": "", + "email": "", + "address": "", + "deliveryDays": "По договорённости", + "minOrder": 0, + "note": "Вымышленный поставщик для знакомства с приложением." + }, + { + "id": "demo-v1-supplier-bakery", + "name": "Демо: пекарня", + "active": true, + "category": "Учебный поставщик", + "contact": "", + "phone": "", + "email": "", + "address": "", + "deliveryDays": "По договорённости", + "minOrder": 0, + "note": "Вымышленный поставщик для знакомства с приложением." + }, + { + "id": "demo-v1-supplier-grocery", + "name": "Демо: бакалея", + "active": true, + "category": "Учебный поставщик", + "contact": "", + "phone": "", + "email": "", + "address": "", + "deliveryDays": "По договорённости", + "minOrder": 0, + "note": "Вымышленный поставщик для знакомства с приложением." + }, + { + "id": "demo-v1-supplier-pack", + "name": "Демо: упаковка", + "active": true, + "category": "Учебный поставщик", + "contact": "", + "phone": "", + "email": "", + "address": "", + "deliveryDays": "По договорённости", + "minOrder": 0, + "note": "Вымышленный поставщик для знакомства с приложением." + } + ], + "scenario": { + "lines": [ + { + "id": "demo-v1-bruschetta-tomato", + "qty": 2 + }, + { + "id": "demo-v1-salmon-cream", + "qty": 1 + }, + { + "id": "demo-v1-caprese", + "qty": 1 + } + ] + } +} diff --git a/public/demo/images/berry-dessert.png b/public/demo/images/berry-dessert.png new file mode 100644 index 0000000..e3f7fe9 Binary files /dev/null and b/public/demo/images/berry-dessert.png differ diff --git a/public/demo/images/bruschetta-tomato.png b/public/demo/images/bruschetta-tomato.png new file mode 100644 index 0000000..046bedf Binary files /dev/null and b/public/demo/images/bruschetta-tomato.png differ diff --git a/public/demo/images/caprese.png b/public/demo/images/caprese.png new file mode 100644 index 0000000..fb18f92 Binary files /dev/null and b/public/demo/images/caprese.png differ diff --git a/public/demo/images/cheese-fruit.png b/public/demo/images/cheese-fruit.png new file mode 100644 index 0000000..052eb08 Binary files /dev/null and b/public/demo/images/cheese-fruit.png differ diff --git a/public/demo/images/chicken-sandwich.png b/public/demo/images/chicken-sandwich.png new file mode 100644 index 0000000..07b8e87 Binary files /dev/null and b/public/demo/images/chicken-sandwich.png differ diff --git a/public/demo/images/meat-assortment.png b/public/demo/images/meat-assortment.png new file mode 100644 index 0000000..0081731 Binary files /dev/null and b/public/demo/images/meat-assortment.png differ diff --git a/public/demo/images/mushroom-tartlet.png b/public/demo/images/mushroom-tartlet.png new file mode 100644 index 0000000..5a02251 Binary files /dev/null and b/public/demo/images/mushroom-tartlet.png differ diff --git a/public/demo/images/salmon-cream.png b/public/demo/images/salmon-cream.png new file mode 100644 index 0000000..9c5b652 Binary files /dev/null and b/public/demo/images/salmon-cream.png differ diff --git a/public/demo/images/turkey-wrap.png b/public/demo/images/turkey-wrap.png new file mode 100644 index 0000000..abcc1e4 Binary files /dev/null and b/public/demo/images/turkey-wrap.png differ diff --git a/public/demo/images/vegetables-hummus.png b/public/demo/images/vegetables-hummus.png new file mode 100644 index 0000000..67e80ac Binary files /dev/null and b/public/demo/images/vegetables-hummus.png differ diff --git a/public/index.html b/public/index.html index faee967..0e4362d 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/service-worker.js b/public/service-worker.js index 0b76513..26fa131 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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=>{ diff --git a/supabase/migrations/20260918010000_trial_demo_catalog.sql b/supabase/migrations/20260918010000_trial_demo_catalog.sql new file mode 100644 index 0000000..7b97e23 --- /dev/null +++ b/supabase/migrations/20260918010000_trial_demo_catalog.sql @@ -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${"version":1,"batch":"trial-demo-v1","boxes":[{"id":"demo-v1-bruschetta-tomato","name":"Брускетты с томатами и базиликом","price":2200,"category":0,"catalogSection":"Демонстрационные боксы","pieces":24,"weight":"840 г","photo":"demo/images/bruschetta-tomato.png","composition":["24 брускетты с томатами, базиликом и оливковым маслом"],"ingredients":[["Багет",0.48,"кг"],["Томаты",0.33,"кг"],["Базилик",0.026,"кг"],["Масло оливковое",0.036,"кг"],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-01","basis":"На 1 готовый бокс","outputGrams":840,"pieces":24,"rows":[{"productId":"demo-v1-stock-baguette","name":"Багет","unit":"кг","gross":0.48,"net":0.48,"unitCost":240},{"productId":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","gross":0.33,"net":0.3,"unitCost":260},{"productId":"demo-v1-stock-basil","name":"Базилик","unit":"кг","gross":0.026,"net":0.024,"unitCost":1500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.036,"net":0.036,"unitCost":1100},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Нарезать багет на 24 одинаковых ломтика и подсушить.","Подготовить томаты и базилик, смешать с маслом.","Распределить начинку по ломтикам, уложить в коробку."],"allergens":["Глютен"],"ingredientCost":344.6,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-salmon-cream","name":"Канапе с лососем и сливочным сыром","price":3200,"category":0,"catalogSection":"Демонстрационные боксы","pieces":16,"weight":"720 г","photo":"demo/images/salmon-cream.png","composition":["16 канапе на ржаном хлебе с лососем, сливочным сыром и огурцом"],"ingredients":[["Хлеб ржаной",0.24,"кг"],["Лосось слабосолёный",0.24,"кг"],["Сыр сливочный",0.16,"кг"],["Огурцы",0.084,"кг"],["Укроп",0.01,"кг"],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-02","basis":"На 1 готовый бокс","outputGrams":720,"pieces":16,"rows":[{"productId":"demo-v1-stock-rye","name":"Хлеб ржаной","unit":"кг","gross":0.24,"net":0.24,"unitCost":220},{"productId":"demo-v1-stock-salmon","name":"Лосось слабосолёный","unit":"кг","gross":0.24,"net":0.24,"unitCost":2400},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.16,"net":0.16,"unitCost":850},{"productId":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","gross":0.084,"net":0.072,"unitCost":220},{"productId":"demo-v1-stock-dill","name":"Укроп","unit":"кг","gross":0.01,"net":0.008,"unitCost":700},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Вырезать из ржаного хлеба 16 основ.","Нанести сливочный сыр, добавить ломтики огурца и лосося.","Украсить укропом и уложить в коробку."],"allergens":["Рыба","Молоко","Глютен"],"ingredientCost":855.28,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-chicken-sandwich","name":"Мини-сэндвичи с курицей","price":2400,"category":0,"catalogSection":"Демонстрационные боксы","pieces":12,"weight":"840 г","photo":"demo/images/chicken-sandwich.png","composition":["12 мини-сэндвичей с готовым куриным филе, томатами, салатом и сливочным сыром"],"ingredients":[["Хлеб тостовый",0.36,"кг"],["Филе курицы готовое",0.24,"кг"],["Сыр сливочный",0.12,"кг"],["Томаты",0.092,"кг"],["Салат листовой",0.045,"кг"],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-03","basis":"На 1 готовый бокс","outputGrams":840,"pieces":12,"rows":[{"productId":"demo-v1-stock-toast","name":"Хлеб тостовый","unit":"кг","gross":0.36,"net":0.36,"unitCost":240},{"productId":"demo-v1-stock-chicken","name":"Филе курицы готовое","unit":"кг","gross":0.24,"net":0.24,"unitCost":850},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.12,"net":0.12,"unitCost":850},{"productId":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","gross":0.092,"net":0.084,"unitCost":260},{"productId":"demo-v1-stock-lettuce","name":"Салат листовой","unit":"кг","gross":0.045,"net":0.036,"unitCost":700},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Нарезать готовое куриное филе, подготовить овощи и салат.","Собрать сэндвичи из тостового хлеба, сыра, курицы и овощей.","Нарезать на 12 одинаковых мини-сэндвичей и упаковать."],"allergens":["Глютен","Молоко"],"ingredientCost":512.82,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-caprese","name":"Канапе «Капрезе»","price":2100,"category":0,"catalogSection":"Демонстрационные боксы","pieces":20,"weight":"700 г","photo":"demo/images/caprese.png","composition":["20 шпажек с моцареллой, черри и базиликом"],"ingredients":[["Моцарелла мини",0.36,"кг"],["Томаты черри",0.308,"кг"],["Базилик",0.022,"кг"],["Масло оливковое",0.04,"кг"],["Шпажка бамбуковая",20,"шт."],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-04","basis":"На 1 готовый бокс","outputGrams":700,"pieces":20,"rows":[{"productId":"demo-v1-stock-mozzarella","name":"Моцарелла мини","unit":"кг","gross":0.36,"net":0.36,"unitCost":950},{"productId":"demo-v1-stock-cherry","name":"Томаты черри","unit":"кг","gross":0.308,"net":0.28,"unitCost":450},{"productId":"demo-v1-stock-basil","name":"Базилик","unit":"кг","gross":0.022,"net":0.02,"unitCost":1500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.04,"net":0.04,"unitCost":1100},{"productId":"demo-v1-stock-skewer","name":"Шпажка бамбуковая","unit":"шт.","gross":20,"net":20,"unitCost":2},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Подготовить черри и базилик, обсушить моцареллу.","Собрать 20 шпажек, распределив ингредиенты поровну.","Добавить оливковое масло, уложить в коробку."],"allergens":["Молоко"],"ingredientCost":662.6,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-mushroom-tartlet","name":"Тарталетки с грибами и сыром","price":2300,"category":0,"catalogSection":"Демонстрационные боксы","pieces":20,"weight":"800 г","photo":"demo/images/mushroom-tartlet.png","composition":["20 тарталеток с шампиньонами, сливками и сыром"],"ingredients":[["Тарталетки готовые",0.2,"кг"],["Шампиньоны",0.44,"кг"],["Сливки",0.1,"кг"],["Сыр гауда",0.12,"кг"],["Лук репчатый",0.088,"кг"],["Масло оливковое",0.02,"кг"],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-05","basis":"На 1 готовый бокс","outputGrams":800,"pieces":20,"rows":[{"productId":"demo-v1-stock-tartlet","name":"Тарталетки готовые","unit":"кг","gross":0.2,"net":0.2,"unitCost":750},{"productId":"demo-v1-stock-mushroom","name":"Шампиньоны","unit":"кг","gross":0.44,"net":0.3,"unitCost":340},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.1,"net":0.1,"unitCost":500},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.12,"net":0.12,"unitCost":900},{"productId":"demo-v1-stock-onion","name":"Лук репчатый","unit":"кг","gross":0.088,"net":0.06,"unitCost":80},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.02,"net":0.02,"unitCost":1100},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.","Добавить сливки и распределить начинку по 20 тарталеткам.","Добавить сыр, запечь до расплавления и упаковать после охлаждения."],"allergens":["Глютен","Молоко"],"ingredientCost":551.64,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-turkey-wrap","name":"Роллы из тортильи с индейкой","price":2700,"category":0,"catalogSection":"Демонстрационные боксы","pieces":25,"weight":"1000 г","photo":"demo/images/turkey-wrap.png","composition":["25 мини-роллов с ветчиной из индейки, сливочным сыром, перцем и салатом"],"ingredients":[["Тортилья",0.32,"кг"],["Ветчина из индейки",0.32,"кг"],["Сыр сливочный",0.18,"кг"],["Салат листовой",0.075,"кг"],["Перец сладкий",0.15,"кг"],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-06","basis":"На 1 готовый бокс","outputGrams":1000,"pieces":25,"rows":[{"productId":"demo-v1-stock-tortilla","name":"Тортилья","unit":"кг","gross":0.32,"net":0.32,"unitCost":450},{"productId":"demo-v1-stock-turkey","name":"Ветчина из индейки","unit":"кг","gross":0.32,"net":0.32,"unitCost":1000},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.18,"net":0.18,"unitCost":850},{"productId":"demo-v1-stock-lettuce","name":"Салат листовой","unit":"кг","gross":0.075,"net":0.06,"unitCost":700},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.15,"net":0.12,"unitCost":380},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Подготовить салат и перец, нарезать ветчину.","Нанести сыр на тортильи, распределить начинку и свернуть плотные рулеты.","Нарезать на 25 порций и уложить срезом вверх."],"allergens":["Глютен","Молоко"],"ingredientCost":791.5,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-cheese-fruit","name":"Сырный бокс с виноградом и орехами","price":2900,"category":0,"catalogSection":"Демонстрационные боксы","pieces":0,"weight":"800 г","photo":"demo/images/cheese-fruit.png","composition":["Бри, гауда и сыр с голубой плесенью","Виноград, грецкий орех, мёд и крекер"],"ingredients":[["Сыр бри",0.2,"кг"],["Сыр гауда",0.18,"кг"],["Сыр с голубой плесенью",0.12,"кг"],["Виноград",0.176,"кг"],["Орех грецкий очищенный",0.05,"кг"],["Мёд",0.04,"кг"],["Крекер",0.05,"кг"],["Соусник",1,"шт."],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-07","basis":"На 1 готовый бокс","outputGrams":800,"pieces":0,"rows":[{"productId":"demo-v1-stock-brie","name":"Сыр бри","unit":"кг","gross":0.2,"net":0.2,"unitCost":1600},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.18,"net":0.18,"unitCost":900},{"productId":"demo-v1-stock-blue","name":"Сыр с голубой плесенью","unit":"кг","gross":0.12,"net":0.12,"unitCost":1700},{"productId":"demo-v1-stock-grape","name":"Виноград","unit":"кг","gross":0.176,"net":0.16,"unitCost":420},{"productId":"demo-v1-stock-walnut","name":"Орех грецкий очищенный","unit":"кг","gross":0.05,"net":0.05,"unitCost":1000},{"productId":"demo-v1-stock-honey","name":"Мёд","unit":"кг","gross":0.04,"net":0.04,"unitCost":800},{"productId":"demo-v1-stock-cracker","name":"Крекер","unit":"кг","gross":0.05,"net":0.05,"unitCost":500},{"productId":"demo-v1-stock-sauce-cup","name":"Соусник","unit":"шт.","gross":1,"net":1,"unitCost":7},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Нарезать сыры порционными кусочками.","Подготовить виноград, переложить мёд в соусник.","Разложить сыры, виноград, орехи и крекер отдельными секциями."],"allergens":["Молоко","Орехи","Глютен"],"ingredientCost":938.92,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-meat-assortment","name":"Мясное ассорти с корнишонами","price":3200,"category":0,"catalogSection":"Демонстрационные боксы","pieces":0,"weight":"700 г","photo":"demo/images/meat-assortment.png","composition":["Салями, готовый ростбиф и ветчина из индейки","Корнишоны, маслины и горчица"],"ingredients":[["Салями",0.18,"кг"],["Ростбиф готовый",0.22,"кг"],["Ветчина из индейки",0.15,"кг"],["Корнишоны",0.09,"кг"],["Маслины без косточек",0.045,"кг"],["Горчица",0.03,"кг"],["Соусник",1,"шт."],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-08","basis":"На 1 готовый бокс","outputGrams":700,"pieces":0,"rows":[{"productId":"demo-v1-stock-salami","name":"Салями","unit":"кг","gross":0.18,"net":0.18,"unitCost":1400},{"productId":"demo-v1-stock-beef","name":"Ростбиф готовый","unit":"кг","gross":0.22,"net":0.22,"unitCost":2000},{"productId":"demo-v1-stock-turkey","name":"Ветчина из индейки","unit":"кг","gross":0.15,"net":0.15,"unitCost":1000},{"productId":"demo-v1-stock-pickle","name":"Корнишоны","unit":"кг","gross":0.09,"net":0.08,"unitCost":400},{"productId":"demo-v1-stock-olive","name":"Маслины без косточек","unit":"кг","gross":0.045,"net":0.04,"unitCost":650},{"productId":"demo-v1-stock-mustard","name":"Горчица","unit":"кг","gross":0.03,"net":0.03,"unitCost":350},{"productId":"demo-v1-stock-sauce-cup","name":"Соусник","unit":"шт.","gross":1,"net":1,"unitCost":7},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Нарезать готовые мясные продукты тонкими ломтиками.","Обсушить корнишоны и маслины, переложить горчицу в соусник.","Разложить ассорти секциями, добавить гарниры."],"allergens":["Горчица"],"ingredientCost":989.75,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-vegetables-hummus","name":"Овощной бокс с хумусом","price":2100,"category":0,"catalogSection":"Демонстрационные боксы","pieces":0,"weight":"1000 г","photo":"demo/images/vegetables-hummus.png","composition":["Морковь, огурцы, сладкий перец и черри","Хумус в отдельном соуснике"],"ingredients":[["Морковь",0.3,"кг"],["Огурцы",0.275,"кг"],["Перец сладкий",0.25,"кг"],["Томаты черри",0.165,"кг"],["Хумус готовый",0.15,"кг"],["Соусник",1,"шт."],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-09","basis":"На 1 готовый бокс","outputGrams":1000,"pieces":0,"rows":[{"productId":"demo-v1-stock-carrot","name":"Морковь","unit":"кг","gross":0.3,"net":0.25,"unitCost":90},{"productId":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","gross":0.275,"net":0.25,"unitCost":220},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.25,"net":0.2,"unitCost":380},{"productId":"demo-v1-stock-cherry","name":"Томаты черри","unit":"кг","gross":0.165,"net":0.15,"unitCost":450},{"productId":"demo-v1-stock-hummus","name":"Хумус готовый","unit":"кг","gross":0.15,"net":0.15,"unitCost":600},{"productId":"demo-v1-stock-sauce-cup","name":"Соусник","unit":"шт.","gross":1,"net":1,"unitCost":7},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Вымыть и подготовить овощи; очистить морковь и перец.","Нарезать овощи палочками, черри оставить целыми.","Переложить хумус в соусник и разложить овощи вокруг него."],"allergens":["Кунжут в составе хумуса"],"ingredientCost":418.75,"note":"Учебные нормы и цены для знакомства с приложением."}},{"id":"demo-v1-berry-dessert","name":"Ягодные мини-десерты","price":2800,"category":0,"catalogSection":"Демонстрационные боксы","pieces":16,"weight":"960 г","photo":"demo/images/berry-dessert.png","composition":["16 десертов со сливочным кремом, бисквитом, клубникой и голубикой"],"ingredients":[["Крошка бисквитная",0.16,"кг"],["Сыр сливочный",0.32,"кг"],["Сливки",0.16,"кг"],["Клубника",0.176,"кг"],["Голубика",0.084,"кг"],["Пудра сахарная",0.08,"кг"],["Стаканчик десертный",16,"шт."],["Коробка для кейтеринга",1,"шт."]],"demo":true,"ttk":{"number":"ДЕМО-10","basis":"На 1 готовый бокс","outputGrams":960,"pieces":16,"rows":[{"productId":"demo-v1-stock-biscuit","name":"Крошка бисквитная","unit":"кг","gross":0.16,"net":0.16,"unitCost":500},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.32,"net":0.32,"unitCost":850},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.16,"net":0.16,"unitCost":500},{"productId":"demo-v1-stock-strawberry","name":"Клубника","unit":"кг","gross":0.176,"net":0.16,"unitCost":750},{"productId":"demo-v1-stock-blueberry","name":"Голубика","unit":"кг","gross":0.084,"net":0.08,"unitCost":1300},{"productId":"demo-v1-stock-sugar","name":"Пудра сахарная","unit":"кг","gross":0.08,"net":0.08,"unitCost":160},{"productId":"demo-v1-stock-cup","name":"Стаканчик десертный","unit":"шт.","gross":16,"net":16,"unitCost":9},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65}],"steps":["Подготовить ягоды, разделить готовый бисквит на крошку.","Смешать сливочный сыр, сливки и сахарную пудру в крем.","Собрать 16 стаканчиков слоями: бисквит, крем, ягоды."],"allergens":["Молоко","Глютен","Яйцо в составе бисквита"],"ingredientCost":895,"note":"Учебные нормы и цены для знакомства с приложением."}}],"stock":[{"id":"demo-v1-stock-baguette","name":"Багет","unit":"кг","cost":240,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","cost":260,"qty":0.19,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-basil","name":"Базилик","unit":"кг","cost":1500,"qty":0.096,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","cost":1100,"qty":0.067,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-rye","name":"Хлеб ржаной","unit":"кг","cost":220,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-salmon","name":"Лосось слабосолёный","unit":"кг","cost":2400,"qty":0.108,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","cost":850,"qty":1.56,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","cost":220,"qty":0.251,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-dill","name":"Укроп","unit":"кг","cost":700,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-toast","name":"Хлеб тостовый","unit":"кг","cost":240,"qty":0.162,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-chicken","name":"Филе курицы готовое","unit":"кг","cost":850,"qty":0.48,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-lettuce","name":"Салат листовой","unit":"кг","cost":700,"qty":0.084,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-mozzarella","name":"Моцарелла мини","unit":"кг","cost":950,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-cherry","name":"Томаты черри","unit":"кг","cost":450,"qty":0.213,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-tartlet","name":"Тарталетки готовые","unit":"кг","cost":750,"qty":0.4,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-mushroom","name":"Шампиньоны","unit":"кг","cost":340,"qty":0.308,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","cost":500,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","cost":900,"qty":0.135,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-onion","name":"Лук репчатый","unit":"кг","cost":80,"qty":0.176,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-tortilla","name":"Тортилья","unit":"кг","cost":450,"qty":0.224,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-turkey","name":"Ветчина из индейки","unit":"кг","cost":1000,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","cost":380,"qty":0.18,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-brie","name":"Сыр бри","unit":"кг","cost":1600,"qty":0.4,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-blue","name":"Сыр с голубой плесенью","unit":"кг","cost":1700,"qty":0.084,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-grape","name":"Виноград","unit":"кг","cost":420,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-walnut","name":"Орех грецкий очищенный","unit":"кг","cost":1000,"qty":0.023,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-honey","name":"Мёд","unit":"кг","cost":800,"qty":0.08,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-cracker","name":"Крекер","unit":"кг","cost":500,"qty":0.035,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-salami","name":"Салями","unit":"кг","cost":1400,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-beef","name":"Ростбиф готовый","unit":"кг","cost":2000,"qty":0.099,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-pickle","name":"Корнишоны","unit":"кг","cost":400,"qty":0.18,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-olive","name":"Маслины без косточек","unit":"кг","cost":650,"qty":0.032,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-mustard","name":"Горчица","unit":"кг","cost":350,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-carrot","name":"Морковь","unit":"кг","cost":90,"qty":0.135,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-hummus","name":"Хумус готовый","unit":"кг","cost":600,"qty":0.3,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-biscuit","name":"Крошка бисквитная","unit":"кг","cost":500,"qty":0.112,"min":0.1,"supplierId":"demo-v1-supplier-bakery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-strawberry","name":"Клубника","unit":"кг","cost":750,"qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-blueberry","name":"Голубика","unit":"кг","cost":1300,"qty":0.038,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-sugar","name":"Пудра сахарная","unit":"кг","cost":160,"qty":0.16,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","cost":65,"qty":7,"min":2,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-skewer","name":"Шпажка бамбуковая","unit":"шт.","cost":2,"qty":0,"min":2,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-cup","name":"Стаканчик десертный","unit":"шт.","cost":9,"qty":7,"min":2,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-v1-stock-sauce-cup","name":"Соусник","unit":"шт.","cost":7,"qty":6,"min":2,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0}],"suppliers":[{"id":"demo-v1-supplier-fresh","name":"Демо: овощи и молочные продукты","active":true,"category":"Учебный поставщик","contact":"","phone":"","email":"","address":"","deliveryDays":"По договорённости","minOrder":0,"note":"Вымышленный поставщик для знакомства с приложением."},{"id":"demo-v1-supplier-protein","name":"Демо: мясо и рыба","active":true,"category":"Учебный поставщик","contact":"","phone":"","email":"","address":"","deliveryDays":"По договорённости","minOrder":0,"note":"Вымышленный поставщик для знакомства с приложением."},{"id":"demo-v1-supplier-bakery","name":"Демо: пекарня","active":true,"category":"Учебный поставщик","contact":"","phone":"","email":"","address":"","deliveryDays":"По договорённости","minOrder":0,"note":"Вымышленный поставщик для знакомства с приложением."},{"id":"demo-v1-supplier-grocery","name":"Демо: бакалея","active":true,"category":"Учебный поставщик","contact":"","phone":"","email":"","address":"","deliveryDays":"По договорённости","minOrder":0,"note":"Вымышленный поставщик для знакомства с приложением."},{"id":"demo-v1-supplier-pack","name":"Демо: упаковка","active":true,"category":"Учебный поставщик","contact":"","phone":"","email":"","address":"","deliveryDays":"По договорённости","minOrder":0,"note":"Вымышленный поставщик для знакомства с приложением."}],"scenario":{"lines":[{"id":"demo-v1-bruschetta-tomato","qty":2},{"id":"demo-v1-salmon-cream","qty":1},{"id":"demo-v1-caprese","qty":1}]}}$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; diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index e1e4b5b..9e9bddc 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -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}, diff --git a/tests/recovery/smoke.mjs b/tests/recovery/smoke.mjs index f0a8a36..6b3e7d1 100644 --- a/tests/recovery/smoke.mjs +++ b/tests/recovery/smoke.mjs @@ -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(); } diff --git a/tests/release-check.mjs b/tests/release-check.mjs index c6161f7..08a6610 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -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'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index 781d47b..aee3948 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -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'); diff --git a/tests/trial-demo.spec.mjs b/tests/trial-demo.spec.mjs new file mode 100644 index 0000000..790459b --- /dev/null +++ b/tests/trial-demo.spec.mjs @@ -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:''}));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'}); +});