From dac1a1de725c4382d9e6e997ed47264f4e7559a5 Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Fri, 18 Sep 2026 08:26:21 +0300 Subject: [PATCH] Fill empty trial tabs with premium, drinks, tableware, extras and delivery --- docs/release-manifest.json | 2 +- ops/demo/build-trial-extras.mjs | 16 + ops/demo/trial-extras-data.mjs | 36 + ops/demo/trial-extras.sql | 48 + package.json | 2 +- public/core/performance.js | 2 +- public/core/trial-demo.js | 14 +- public/demo/extras-v1.json | 983 ++++++++++++++++++ public/index.html | 6 +- public/service-worker.js | 4 +- .../20260918083000_trial_extras.sql | 134 +++ tests/recovery/smoke.mjs | 38 +- tests/release-check.mjs | 10 +- tests/static-security.mjs | 4 +- tests/trial-demo.spec.mjs | 29 +- 15 files changed, 1298 insertions(+), 30 deletions(-) create mode 100644 ops/demo/build-trial-extras.mjs create mode 100644 ops/demo/trial-extras-data.mjs create mode 100644 ops/demo/trial-extras.sql create mode 100644 public/demo/extras-v1.json create mode 100644 supabase/migrations/20260918083000_trial_extras.sql diff --git a/docs/release-manifest.json b/docs/release-manifest.json index 50088e9..773fce8 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v97-20260918-trial-banquet", + "pwaCache": "v98-20260918-trial-extras", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, diff --git a/ops/demo/build-trial-extras.mjs b/ops/demo/build-trial-extras.mjs new file mode 100644 index 0000000..72fbb94 --- /dev/null +++ b/ops/demo/build-trial-extras.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +import {extrasDemo} from './trial-extras-data.mjs'; +const root=new URL('../../',import.meta.url); +const original=fs.readFileSync(new URL('supabase/migrations/20260918060000_trial_banquet.sql',root),'utf8'); +const block=name=>{const start=original.indexOf(`create or replace function public.${name}(`);assert.ok(start>=0);return original.slice(start,original.indexOf('end $fn$;',start)+10)}; +let status=block('caterium_trial_demo_status'); +const old="and coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1'"; +assert.ok(status.includes(old));status=status.replace(old,"and (coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1' or coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')<>'1')"); +let banquet=block('caterium_install_trial_banquet'); +banquet=banquet.replace("if not (v_status->>'canUpgrade')::boolean then return v_status; end if;","if coalesce((select payload#>>'{storage,sunTrialDemoV1,v,banquetVersion}' from public.sun_app_state where workspace_id=p_workspace),'')='1' then return v_status; end if;"); +const install=block('caterium_install_trial_demo').replaceAll('return public.caterium_install_trial_banquet(p_workspace);','perform public.caterium_install_trial_banquet(p_workspace); return public.caterium_install_trial_extras(p_workspace);'); +const template=fs.readFileSync(new URL('trial-extras.sql',import.meta.url),'utf8'); +fs.writeFileSync(new URL('public/demo/extras-v1.json',root),JSON.stringify(extrasDemo,null,2)+'\n'); +fs.writeFileSync(new URL('supabase/migrations/20260918083000_trial_extras.sql',root),template.replace('__EXTRAS_JSON__',JSON.stringify(extrasDemo)).replace('__STATUS_FUNCTION__',status).replace('__BANQUET_FUNCTION__',banquet).replace('__INSTALL_FUNCTION__',install)); +console.log(`Built ${extrasDemo.items.length} items across five trial categories and ${extrasDemo.stock.length} stock products.`); diff --git a/ops/demo/trial-extras-data.mjs b/ops/demo/trial-extras-data.mjs new file mode 100644 index 0000000..b030fb6 --- /dev/null +++ b/ops/demo/trial-extras-data.mjs @@ -0,0 +1,36 @@ +// Fictional items for empty trial tabs. Native category IDs must not follow visual order. +import {boxes} from './trial-demo-data.mjs'; +const round=n=>Math.round(n*1000)/1000; +const mixes=[ + ['salmon-caprese','Премиум-сет «Лосось и капрезе»',3500,18,['salmon-cream','caprese']], + ['meat-cheese','Премиум-сет «Мясо и сыры»',3900,0,['meat-assortment','cheese-fruit']], + ['mini-buffet','Премиум-сет «Мини-фуршет»',2900,22,['bruschetta-tomato','mushroom-tartlet']] +]; +const premium=mixes.map(([key,name,price,pieces,sourceIds],index)=>{ + const sources=sourceIds.map(id=>boxes.find(b=>b.id===`demo-v1-${id}`)),map=new Map(); + for(const b of sources)for(const r of b.ttk.rows){const old=map.get(r.productId)||{...r,gross:0,net:0};old.gross=round(old.gross+r.gross/2);old.net=round(old.net+r.net/2);map.set(r.productId,old)} + const rows=[...map.values()],outputGrams=Math.round(rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0)); + return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:'',demo:true, + composition:sources.map(b=>'Половина стандартного бокса: '+b.name),ingredients:rows.map(r=>[r.name,r.gross,r.unit]), + ttk:{number:`ДЕМО-П${index+1}`,basis:'На 1 готовый премиум-сет',outputGrams,pieces,rows, + steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'], + allergens:[...new Set(sources.flatMap(b=>b.ttk.allergens))],ingredientCost:Math.round(rows.reduce((s,r)=>s+r.gross*r.unitCost,0)*100)/100,note:'Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет.'}}; +}); +const specs=[ + // key, category, sale name, sale price, stock name, purchase cost, unit, supplier, description + ['water',3,'Вода негазированная, 0,5 л',90,'Демо: вода негазированная 0,5 л',35,'шт.','grocery','Одна бутылка 0,5 л'], + ['sparkling',3,'Вода газированная, 0,5 л',100,'Демо: вода газированная 0,5 л',40,'шт.','grocery','Одна бутылка 0,5 л'], + ['juice',3,'Яблочный сок, 1 л',220,'Демо: сок яблочный 1 л',110,'шт.','grocery','Одна упаковка 1 л'], + ['mors',3,'Клюквенный морс, 1 л',280,'Демо: морс клюквенный 1 л',140,'шт.','grocery','Одна бутылка готового морса 1 л'], + ['plate',1,'Тарелка одноразовая, 1 шт.',25,'Демо: тарелка одноразовая',12,'шт.','pack','Одна сервировочная тарелка'], + ['fork',1,'Вилка одноразовая, 1 шт.',10,'Демо: вилка одноразовая',4,'шт.','pack','Одна вилка'], + ['glass',1,'Стакан одноразовый, 250 мл',15,'Демо: стакан одноразовый 250 мл',6,'шт.','pack','Один стакан'], + ['napkin',1,'Салфетка сервировочная, 1 шт.',10,'Демо: салфетка сервировочная',3,'шт.','pack','Одна бумажная салфетка'], + ['ice',2,'Лёд пищевой, пакет 1 кг',180,'Демо: лёд пищевой',70,'кг','grocery','Один пакет пищевого льда, 1 кг'], + ['tablecloth',2,'Скатерть одноразовая, 1 шт.',250,'Демо: скатерть одноразовая',110,'шт.','pack','Одна одноразовая скатерть 120 × 180 см'], + ['serving-kit',2,'Набор для подачи: щипцы и лопатка',190,'Демо: набор щипцы и лопатка',85,'шт.','pack','Один комплект одноразовых приборов для подачи'] +]; +const stock=specs.map(([key,category,name,price,productName,cost,unit,supplier])=>({id:`demo-extras-v1-stock-${key}`,name:productName,cost,unit,qty:0,min:unit==='кг'?1:10,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0})); +const packaged=specs.map(([key,category,name,price,productName,cost,unit,supplier,description])=>({id:`demo-extras-v1-${key}`,name,category,catalogSection:({1:'Одноразовая посуда',2:'Для сервировки',3:'Безалкогольные напитки'})[category],price,pieces:1,weight:key==='ice'?'1 кг':'',photo:'',composition:[description],ingredients:[[productName,1,unit]],demo:true})); +const delivery=[['city','Доставка по городу',600,'Учебный тариф за один адрес в пределах города.'],['outer','Доставка за город',1200,'Учебный тариф за один адрес в ближайшем пригороде.']].map(([key,name,price,note])=>({id:`demo-extras-v1-delivery-${key}`,name,category:4,catalogSection:'Учебные тарифы доставки',price,pieces:0,weight:'',photo:'',composition:[note],ingredients:[],demo:true})); +export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock}; diff --git a/ops/demo/trial-extras.sql b/ops/demo/trial-extras.sql new file mode 100644 index 0000000..6ed9d42 --- /dev/null +++ b/ops/demo/trial-extras.sql @@ -0,0 +1,48 @@ +-- Generated by ops/demo/build-trial-extras.mjs. Fill empty trial categories only. +begin; +create or replace function public.caterium_trial_extras_seed_v1() +returns jsonb language sql immutable security definer set search_path=public,pg_temp +as $fn$ select $demo$__EXTRAS_JSON__$demo$::jsonb $fn$; +revoke all on function public.caterium_trial_extras_seed_v1() from public,anon,authenticated; + +__STATUS_FUNCTION__ + +create or replace function public.caterium_install_trial_extras(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_storage jsonb; v_seed jsonb; + v_catalog jsonb; v_stock jsonb; v_added jsonb; v_products 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 not (v_status->>'installed')::boolean then raise exception 'Сначала установите демонстрационную базу'; end if; + select * into v_state from public.sun_app_state where workspace_id=p_workspace for update; + v_storage:=v_state.payload->'storage'; + if coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')='1' then return v_status; end if; + v_catalog:=coalesce(v_storage#>'{sunBoxes,v}','[]'::jsonb);v_stock:=coalesce(v_storage#>'{sunStock,v}','[]'::jsonb); + if jsonb_typeof(v_catalog)<>'array' or jsonb_typeof(v_stock)<>'array' then raise exception 'Неверный формат каталога или склада. Данные сохранены без изменений'; end if; + v_seed:=public.caterium_trial_extras_seed_v1(); + select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_added + from jsonb_array_elements(v_seed->'items') with ordinality as x(n,ord) + where not exists(select 1 from jsonb_array_elements(v_catalog) old where coalesce(old->>'category','0')=n->>'category' or old->>'id'=n->>'id'); + select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_products + from jsonb_array_elements(v_seed->'stock') with ordinality as x(n,ord) + where not exists(select 1 from jsonb_array_elements(v_stock) old where old->>'id'=n->>'id' or (old->>'name'=n->>'name' and old->>'unit'=n->>'unit')) + and exists(select 1 from jsonb_array_elements(v_added) i cross join lateral jsonb_array_elements(i->'ingredients') r where r->>0=n->>'name' and r->>2=n->>'unit'); + v_marker:=(v_storage#>'{sunTrialDemoV1,v}')||jsonb_build_object('extrasVersion',1,'extrasInstalledAt',now()); + v_storage:=v_storage||jsonb_build_object('sunBoxes',jsonb_build_object('t','j','v',v_catalog||v_added),'sunStock',jsonb_build_object('t','j','v',v_stock||v_products),'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-extras-v1',v_state.revision); + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details) + values(auth.uid(),'trial_demo.extras_install',p_workspace,jsonb_build_object('version',1,'items',jsonb_array_length(v_added))); + return public.caterium_trial_demo_status(p_workspace); +end $fn$; +revoke all on function public.caterium_install_trial_extras(uuid) from public,anon; +grant execute on function public.caterium_install_trial_extras(uuid) to authenticated; + +__BANQUET_FUNCTION__ +__INSTALL_FUNCTION__ +notify pgrst,'reload schema'; +commit; diff --git a/package.json b/package.json index 04a8bc4..4356901 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:e2e": "playwright test --config=tests/playwright.config.mjs", "test": "npm run check:deploy", "deploy:cloudflare-backup": "wrangler deploy", - "build:demo": "node ops/demo/build-trial-demo.mjs && node ops/demo/build-trial-banquet.mjs" + "build:demo": "node ops/demo/build-trial-demo.mjs && node ops/demo/build-trial-banquet.mjs && node ops/demo/build-trial-extras.mjs" }, "devDependencies": { "@electric-sql/pglite": "0.5.8", diff --git a/public/core/performance.js b/public/core/performance.js index 807f607..0bd816f 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-trial-banquet'; + const RELEASE='20260918-trial-extras'; 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 index cad38ee..b089fae 100644 --- a/public/core/trial-demo.js +++ b/public/core/trial-demo.js @@ -29,17 +29,17 @@ } function render(){ const host=$('new');if(!host)return; - const items=catalog().filter(b=>b.demo&&b.ttk),active=installed()&&items.length>0; + const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.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]),upgrade:status?.canUpgrade,can:writable()}); + const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()}); if(card.dataset.signature===signature)return;card.dataset.signature=signature; - card.innerHTML=active?`
Демонстрационная база

${items.filter(b=>Number(b.category)!==6).length} боксов с фото · ${items.filter(b=>Number(b.category)===6).length} банкетных блюд · ТТК, учебные остатки и поставщики

Можно пробовать
+ card.innerHTML=active?`
Демонстрационная база

${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики

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

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

- ${status?.canUpgrade?'':''} -
Технологические карты боксов и блюд

`: - `
Попробуйте приложение на готовом меню

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

`; + ${status?.canUpgrade?'':''} +
Технологические карты боксов и блюд

`: + `
Попробуйте приложение на готовом меню

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

`; 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)); @@ -48,7 +48,7 @@ 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();} + 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;} } diff --git a/public/demo/extras-v1.json b/public/demo/extras-v1.json new file mode 100644 index 0000000..79896fe --- /dev/null +++ b/public/demo/extras-v1.json @@ -0,0 +1,983 @@ +{ + "version": 1, + "items": [ + { + "id": "demo-extras-v1-salmon-caprese", + "name": "Премиум-сет «Лосось и капрезе»", + "category": 5, + "catalogSection": "Демонстрационные премиум-сеты", + "price": 3500, + "pieces": 18, + "weight": "710 г", + "photo": "", + "demo": true, + "composition": [ + "Половина стандартного бокса: Канапе с лососем и сливочным сыром", + "Половина стандартного бокса: Канапе «Капрезе»" + ], + "ingredients": [ + [ + "Хлеб ржаной", + 0.12, + "кг" + ], + [ + "Лосось слабосолёный", + 0.12, + "кг" + ], + [ + "Сыр сливочный", + 0.08, + "кг" + ], + [ + "Огурцы", + 0.042, + "кг" + ], + [ + "Укроп", + 0.005, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ], + [ + "Моцарелла мини", + 0.18, + "кг" + ], + [ + "Томаты черри", + 0.154, + "кг" + ], + [ + "Базилик", + 0.011, + "кг" + ], + [ + "Масло оливковое", + 0.02, + "кг" + ], + [ + "Шпажка бамбуковая", + 10, + "шт." + ] + ], + "ttk": { + "number": "ДЕМО-П1", + "basis": "На 1 готовый премиум-сет", + "outputGrams": 710, + "pieces": 18, + "rows": [ + { + "productId": "demo-v1-stock-rye", + "name": "Хлеб ржаной", + "unit": "кг", + "gross": 0.12, + "net": 0.12, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-salmon", + "name": "Лосось слабосолёный", + "unit": "кг", + "gross": 0.12, + "net": 0.12, + "unitCost": 2400 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.08, + "net": 0.08, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "gross": 0.042, + "net": 0.036, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-dill", + "name": "Укроп", + "unit": "кг", + "gross": 0.005, + "net": 0.004, + "unitCost": 700 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + }, + { + "productId": "demo-v1-stock-mozzarella", + "name": "Моцарелла мини", + "unit": "кг", + "gross": 0.18, + "net": 0.18, + "unitCost": 950 + }, + { + "productId": "demo-v1-stock-cherry", + "name": "Томаты черри", + "unit": "кг", + "gross": 0.154, + "net": 0.14, + "unitCost": 450 + }, + { + "productId": "demo-v1-stock-basil", + "name": "Базилик", + "unit": "кг", + "gross": 0.011, + "net": 0.01, + "unitCost": 1500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 1100 + }, + { + "productId": "demo-v1-stock-skewer", + "name": "Шпажка бамбуковая", + "unit": "шт.", + "gross": 10, + "net": 10, + "unitCost": 2 + } + ], + "steps": [ + "Приготовить две мини-подборки в половинном объёме по указанным нормам.", + "Канапе с лососем и сливочным сыром: Вырезать из ржаного хлеба 16 основ.", + "Канапе с лососем и сливочным сыром: Нанести сливочный сыр, добавить ломтики огурца и лосося.", + "Канапе с лососем и сливочным сыром: Украсить укропом и уложить в коробку.", + "Канапе «Капрезе»: Подготовить черри и базилик, обсушить моцареллу.", + "Канапе «Капрезе»: Собрать 20 шпажек, распределив ингредиенты поровну.", + "Канапе «Капрезе»: Добавить оливковое масло, уложить в коробку.", + "Уложить обе подборки в одну общую коробку." + ], + "allergens": [ + "Рыба", + "Молоко", + "Глютен" + ], + "ingredientCost": 758.94, + "note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет." + } + }, + { + "id": "demo-extras-v1-meat-cheese", + "name": "Премиум-сет «Мясо и сыры»", + "category": 5, + "catalogSection": "Демонстрационные премиум-сеты", + "price": 3900, + "pieces": 0, + "weight": "750 г", + "photo": "", + "demo": true, + "composition": [ + "Половина стандартного бокса: Мясное ассорти с корнишонами", + "Половина стандартного бокса: Сырный бокс с виноградом и орехами" + ], + "ingredients": [ + [ + "Салями", + 0.09, + "кг" + ], + [ + "Ростбиф готовый", + 0.11, + "кг" + ], + [ + "Ветчина из индейки", + 0.075, + "кг" + ], + [ + "Корнишоны", + 0.045, + "кг" + ], + [ + "Маслины без косточек", + 0.023, + "кг" + ], + [ + "Горчица", + 0.015, + "кг" + ], + [ + "Соусник", + 1, + "шт." + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ], + [ + "Сыр бри", + 0.1, + "кг" + ], + [ + "Сыр гауда", + 0.09, + "кг" + ], + [ + "Сыр с голубой плесенью", + 0.06, + "кг" + ], + [ + "Виноград", + 0.088, + "кг" + ], + [ + "Орех грецкий очищенный", + 0.025, + "кг" + ], + [ + "Мёд", + 0.02, + "кг" + ], + [ + "Крекер", + 0.025, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-П2", + "basis": "На 1 готовый премиум-сет", + "outputGrams": 750, + "pieces": 0, + "rows": [ + { + "productId": "demo-v1-stock-salami", + "name": "Салями", + "unit": "кг", + "gross": 0.09, + "net": 0.09, + "unitCost": 1400 + }, + { + "productId": "demo-v1-stock-beef", + "name": "Ростбиф готовый", + "unit": "кг", + "gross": 0.11, + "net": 0.11, + "unitCost": 2000 + }, + { + "productId": "demo-v1-stock-turkey", + "name": "Ветчина из индейки", + "unit": "кг", + "gross": 0.075, + "net": 0.075, + "unitCost": 1000 + }, + { + "productId": "demo-v1-stock-pickle", + "name": "Корнишоны", + "unit": "кг", + "gross": 0.045, + "net": 0.04, + "unitCost": 400 + }, + { + "productId": "demo-v1-stock-olive", + "name": "Маслины без косточек", + "unit": "кг", + "gross": 0.023, + "net": 0.02, + "unitCost": 650 + }, + { + "productId": "demo-v1-stock-mustard", + "name": "Горчица", + "unit": "кг", + "gross": 0.015, + "net": 0.015, + "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 + }, + { + "productId": "demo-v1-stock-brie", + "name": "Сыр бри", + "unit": "кг", + "gross": 0.1, + "net": 0.1, + "unitCost": 1600 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.09, + "net": 0.09, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-blue", + "name": "Сыр с голубой плесенью", + "unit": "кг", + "gross": 0.06, + "net": 0.06, + "unitCost": 1700 + }, + { + "productId": "demo-v1-stock-grape", + "name": "Виноград", + "unit": "кг", + "gross": 0.088, + "net": 0.08, + "unitCost": 420 + }, + { + "productId": "demo-v1-stock-walnut", + "name": "Орех грецкий очищенный", + "unit": "кг", + "gross": 0.025, + "net": 0.025, + "unitCost": 1000 + }, + { + "productId": "demo-v1-stock-honey", + "name": "Мёд", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 800 + }, + { + "productId": "demo-v1-stock-cracker", + "name": "Крекер", + "unit": "кг", + "gross": 0.025, + "net": 0.025, + "unitCost": 500 + } + ], + "steps": [ + "Приготовить две мини-подборки в половинном объёме по указанным нормам.", + "Мясное ассорти с корнишонами: Нарезать готовые мясные продукты тонкими ломтиками.", + "Мясное ассорти с корнишонами: Обсушить корнишоны и маслины, переложить горчицу в соусник.", + "Мясное ассорти с корнишонами: Разложить ассорти секциями, добавить гарниры.", + "Сырный бокс с виноградом и орехами: Нарезать сыры порционными кусочками.", + "Сырный бокс с виноградом и орехами: Подготовить виноград, переложить мёд в соусник.", + "Сырный бокс с виноградом и орехами: Разложить сыры, виноград, орехи и крекер отдельными секциями.", + "Уложить обе подборки в одну общую коробку." + ], + "allergens": [ + "Горчица", + "Молоко", + "Орехи", + "Глютен" + ], + "ingredientCost": 964.66, + "note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет." + } + }, + { + "id": "demo-extras-v1-mini-buffet", + "name": "Премиум-сет «Мини-фуршет»", + "category": 5, + "catalogSection": "Демонстрационные премиум-сеты", + "price": 2900, + "pieces": 22, + "weight": "820 г", + "photo": "", + "demo": true, + "composition": [ + "Половина стандартного бокса: Брускетты с томатами и базиликом", + "Половина стандартного бокса: Тарталетки с грибами и сыром" + ], + "ingredients": [ + [ + "Багет", + 0.24, + "кг" + ], + [ + "Томаты", + 0.165, + "кг" + ], + [ + "Базилик", + 0.013, + "кг" + ], + [ + "Масло оливковое", + 0.028, + "кг" + ], + [ + "Коробка для кейтеринга", + 1, + "шт." + ], + [ + "Тарталетки готовые", + 0.1, + "кг" + ], + [ + "Шампиньоны", + 0.22, + "кг" + ], + [ + "Сливки", + 0.05, + "кг" + ], + [ + "Сыр гауда", + 0.06, + "кг" + ], + [ + "Лук репчатый", + 0.044, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-П3", + "basis": "На 1 готовый премиум-сет", + "outputGrams": 820, + "pieces": 22, + "rows": [ + { + "productId": "demo-v1-stock-baguette", + "name": "Багет", + "unit": "кг", + "gross": 0.24, + "net": 0.24, + "unitCost": 240 + }, + { + "productId": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "gross": 0.165, + "net": 0.15, + "unitCost": 260 + }, + { + "productId": "demo-v1-stock-basil", + "name": "Базилик", + "unit": "кг", + "gross": 0.013, + "net": 0.012, + "unitCost": 1500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.028, + "net": 0.028, + "unitCost": 1100 + }, + { + "productId": "demo-v1-stock-box", + "name": "Коробка для кейтеринга", + "unit": "шт.", + "gross": 1, + "net": 1, + "unitCost": 65 + }, + { + "productId": "demo-v1-stock-tartlet", + "name": "Тарталетки готовые", + "unit": "кг", + "gross": 0.1, + "net": 0.1, + "unitCost": 750 + }, + { + "productId": "demo-v1-stock-mushroom", + "name": "Шампиньоны", + "unit": "кг", + "gross": 0.22, + "net": 0.15, + "unitCost": 340 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.05, + "net": 0.05, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.06, + "net": 0.06, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-onion", + "name": "Лук репчатый", + "unit": "кг", + "gross": 0.044, + "net": 0.03, + "unitCost": 80 + } + ], + "steps": [ + "Приготовить две мини-подборки в половинном объёме по указанным нормам.", + "Брускетты с томатами и базиликом: Нарезать багет на 24 одинаковых ломтика и подсушить.", + "Брускетты с томатами и базиликом: Подготовить томаты и базилик, смешать с маслом.", + "Брускетты с томатами и базиликом: Распределить начинку по ломтикам, уложить в коробку.", + "Тарталетки с грибами и сыром: Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.", + "Тарталетки с грибами и сыром: Добавить сливки и распределить начинку по 20 тарталеткам.", + "Тарталетки с грибами и сыром: Добавить сыр, запечь до расплавления и упаковать после охлаждения.", + "Уложить обе подборки в одну общую коробку." + ], + "allergens": [ + "Глютен", + "Молоко" + ], + "ingredientCost": 448.12, + "note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет." + } + }, + { + "id": "demo-extras-v1-water", + "name": "Вода негазированная, 0,5 л", + "category": 3, + "catalogSection": "Безалкогольные напитки", + "price": 90, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна бутылка 0,5 л" + ], + "ingredients": [ + [ + "Демо: вода негазированная 0,5 л", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-sparkling", + "name": "Вода газированная, 0,5 л", + "category": 3, + "catalogSection": "Безалкогольные напитки", + "price": 100, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна бутылка 0,5 л" + ], + "ingredients": [ + [ + "Демо: вода газированная 0,5 л", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-juice", + "name": "Яблочный сок, 1 л", + "category": 3, + "catalogSection": "Безалкогольные напитки", + "price": 220, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна упаковка 1 л" + ], + "ingredients": [ + [ + "Демо: сок яблочный 1 л", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-mors", + "name": "Клюквенный морс, 1 л", + "category": 3, + "catalogSection": "Безалкогольные напитки", + "price": 280, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна бутылка готового морса 1 л" + ], + "ingredients": [ + [ + "Демо: морс клюквенный 1 л", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-plate", + "name": "Тарелка одноразовая, 1 шт.", + "category": 1, + "catalogSection": "Одноразовая посуда", + "price": 25, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна сервировочная тарелка" + ], + "ingredients": [ + [ + "Демо: тарелка одноразовая", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-fork", + "name": "Вилка одноразовая, 1 шт.", + "category": 1, + "catalogSection": "Одноразовая посуда", + "price": 10, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна вилка" + ], + "ingredients": [ + [ + "Демо: вилка одноразовая", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-glass", + "name": "Стакан одноразовый, 250 мл", + "category": 1, + "catalogSection": "Одноразовая посуда", + "price": 15, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Один стакан" + ], + "ingredients": [ + [ + "Демо: стакан одноразовый 250 мл", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-napkin", + "name": "Салфетка сервировочная, 1 шт.", + "category": 1, + "catalogSection": "Одноразовая посуда", + "price": 10, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна бумажная салфетка" + ], + "ingredients": [ + [ + "Демо: салфетка сервировочная", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-ice", + "name": "Лёд пищевой, пакет 1 кг", + "category": 2, + "catalogSection": "Для сервировки", + "price": 180, + "pieces": 1, + "weight": "1 кг", + "photo": "", + "composition": [ + "Один пакет пищевого льда, 1 кг" + ], + "ingredients": [ + [ + "Демо: лёд пищевой", + 1, + "кг" + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-tablecloth", + "name": "Скатерть одноразовая, 1 шт.", + "category": 2, + "catalogSection": "Для сервировки", + "price": 250, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Одна одноразовая скатерть 120 × 180 см" + ], + "ingredients": [ + [ + "Демо: скатерть одноразовая", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-serving-kit", + "name": "Набор для подачи: щипцы и лопатка", + "category": 2, + "catalogSection": "Для сервировки", + "price": 190, + "pieces": 1, + "weight": "", + "photo": "", + "composition": [ + "Один комплект одноразовых приборов для подачи" + ], + "ingredients": [ + [ + "Демо: набор щипцы и лопатка", + 1, + "шт." + ] + ], + "demo": true + }, + { + "id": "demo-extras-v1-delivery-city", + "name": "Доставка по городу", + "category": 4, + "catalogSection": "Учебные тарифы доставки", + "price": 600, + "pieces": 0, + "weight": "", + "photo": "", + "composition": [ + "Учебный тариф за один адрес в пределах города." + ], + "ingredients": [], + "demo": true + }, + { + "id": "demo-extras-v1-delivery-outer", + "name": "Доставка за город", + "category": 4, + "catalogSection": "Учебные тарифы доставки", + "price": 1200, + "pieces": 0, + "weight": "", + "photo": "", + "composition": [ + "Учебный тариф за один адрес в ближайшем пригороде." + ], + "ingredients": [], + "demo": true + } + ], + "stock": [ + { + "id": "demo-extras-v1-stock-water", + "name": "Демо: вода негазированная 0,5 л", + "cost": 35, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-sparkling", + "name": "Демо: вода газированная 0,5 л", + "cost": 40, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-juice", + "name": "Демо: сок яблочный 1 л", + "cost": 110, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-mors", + "name": "Демо: морс клюквенный 1 л", + "cost": 140, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-plate", + "name": "Демо: тарелка одноразовая", + "cost": 12, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-fork", + "name": "Демо: вилка одноразовая", + "cost": 4, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-glass", + "name": "Демо: стакан одноразовый 250 мл", + "cost": 6, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-napkin", + "name": "Демо: салфетка сервировочная", + "cost": 3, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-ice", + "name": "Демо: лёд пищевой", + "cost": 70, + "unit": "кг", + "qty": 0, + "min": 1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-tablecloth", + "name": "Демо: скатерть одноразовая", + "cost": 110, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-extras-v1-stock-serving-kit", + "name": "Демо: набор щипцы и лопатка", + "cost": 85, + "unit": "шт.", + "qty": 0, + "min": 10, + "supplierId": "demo-v1-supplier-pack", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + } + ] +} diff --git a/public/index.html b/public/index.html index 1c76725..e80934d 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 80bf082..b0d2eb8 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ -const CACHE='sun-catering-pwa-v97-20260918-trial-banquet'; -const VERSION='20260918-trial-banquet'; +const CACHE='sun-catering-pwa-v98-20260918-trial-extras'; +const VERSION='20260918-trial-extras'; const CORE=[ './','./index.html',`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./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/login-signature-v1776.css?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}`, diff --git a/supabase/migrations/20260918083000_trial_extras.sql b/supabase/migrations/20260918083000_trial_extras.sql new file mode 100644 index 0000000..a58d9e1 --- /dev/null +++ b/supabase/migrations/20260918083000_trial_extras.sql @@ -0,0 +1,134 @@ +-- Generated by ops/demo/build-trial-extras.mjs. Fill empty trial categories only. +begin; +create or replace function public.caterium_trial_extras_seed_v1() +returns jsonb language sql immutable security definer set search_path=public,pg_temp +as $fn$ select $demo${"version":1,"items":[{"id":"demo-extras-v1-salmon-caprese","name":"Премиум-сет «Лосось и капрезе»","category":5,"catalogSection":"Демонстрационные премиум-сеты","price":3500,"pieces":18,"weight":"710 г","photo":"","demo":true,"composition":["Половина стандартного бокса: Канапе с лососем и сливочным сыром","Половина стандартного бокса: Канапе «Капрезе»"],"ingredients":[["Хлеб ржаной",0.12,"кг"],["Лосось слабосолёный",0.12,"кг"],["Сыр сливочный",0.08,"кг"],["Огурцы",0.042,"кг"],["Укроп",0.005,"кг"],["Коробка для кейтеринга",1,"шт."],["Моцарелла мини",0.18,"кг"],["Томаты черри",0.154,"кг"],["Базилик",0.011,"кг"],["Масло оливковое",0.02,"кг"],["Шпажка бамбуковая",10,"шт."]],"ttk":{"number":"ДЕМО-П1","basis":"На 1 готовый премиум-сет","outputGrams":710,"pieces":18,"rows":[{"productId":"demo-v1-stock-rye","name":"Хлеб ржаной","unit":"кг","gross":0.12,"net":0.12,"unitCost":220},{"productId":"demo-v1-stock-salmon","name":"Лосось слабосолёный","unit":"кг","gross":0.12,"net":0.12,"unitCost":2400},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.08,"net":0.08,"unitCost":850},{"productId":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","gross":0.042,"net":0.036,"unitCost":220},{"productId":"demo-v1-stock-dill","name":"Укроп","unit":"кг","gross":0.005,"net":0.004,"unitCost":700},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65},{"productId":"demo-v1-stock-mozzarella","name":"Моцарелла мини","unit":"кг","gross":0.18,"net":0.18,"unitCost":950},{"productId":"demo-v1-stock-cherry","name":"Томаты черри","unit":"кг","gross":0.154,"net":0.14,"unitCost":450},{"productId":"demo-v1-stock-basil","name":"Базилик","unit":"кг","gross":0.011,"net":0.01,"unitCost":1500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.02,"net":0.02,"unitCost":1100},{"productId":"demo-v1-stock-skewer","name":"Шпажка бамбуковая","unit":"шт.","gross":10,"net":10,"unitCost":2}],"steps":["Приготовить две мини-подборки в половинном объёме по указанным нормам.","Канапе с лососем и сливочным сыром: Вырезать из ржаного хлеба 16 основ.","Канапе с лососем и сливочным сыром: Нанести сливочный сыр, добавить ломтики огурца и лосося.","Канапе с лососем и сливочным сыром: Украсить укропом и уложить в коробку.","Канапе «Капрезе»: Подготовить черри и базилик, обсушить моцареллу.","Канапе «Капрезе»: Собрать 20 шпажек, распределив ингредиенты поровну.","Канапе «Капрезе»: Добавить оливковое масло, уложить в коробку.","Уложить обе подборки в одну общую коробку."],"allergens":["Рыба","Молоко","Глютен"],"ingredientCost":758.94,"note":"Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."}},{"id":"demo-extras-v1-meat-cheese","name":"Премиум-сет «Мясо и сыры»","category":5,"catalogSection":"Демонстрационные премиум-сеты","price":3900,"pieces":0,"weight":"750 г","photo":"","demo":true,"composition":["Половина стандартного бокса: Мясное ассорти с корнишонами","Половина стандартного бокса: Сырный бокс с виноградом и орехами"],"ingredients":[["Салями",0.09,"кг"],["Ростбиф готовый",0.11,"кг"],["Ветчина из индейки",0.075,"кг"],["Корнишоны",0.045,"кг"],["Маслины без косточек",0.023,"кг"],["Горчица",0.015,"кг"],["Соусник",1,"шт."],["Коробка для кейтеринга",1,"шт."],["Сыр бри",0.1,"кг"],["Сыр гауда",0.09,"кг"],["Сыр с голубой плесенью",0.06,"кг"],["Виноград",0.088,"кг"],["Орех грецкий очищенный",0.025,"кг"],["Мёд",0.02,"кг"],["Крекер",0.025,"кг"]],"ttk":{"number":"ДЕМО-П2","basis":"На 1 готовый премиум-сет","outputGrams":750,"pieces":0,"rows":[{"productId":"demo-v1-stock-salami","name":"Салями","unit":"кг","gross":0.09,"net":0.09,"unitCost":1400},{"productId":"demo-v1-stock-beef","name":"Ростбиф готовый","unit":"кг","gross":0.11,"net":0.11,"unitCost":2000},{"productId":"demo-v1-stock-turkey","name":"Ветчина из индейки","unit":"кг","gross":0.075,"net":0.075,"unitCost":1000},{"productId":"demo-v1-stock-pickle","name":"Корнишоны","unit":"кг","gross":0.045,"net":0.04,"unitCost":400},{"productId":"demo-v1-stock-olive","name":"Маслины без косточек","unit":"кг","gross":0.023,"net":0.02,"unitCost":650},{"productId":"demo-v1-stock-mustard","name":"Горчица","unit":"кг","gross":0.015,"net":0.015,"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},{"productId":"demo-v1-stock-brie","name":"Сыр бри","unit":"кг","gross":0.1,"net":0.1,"unitCost":1600},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.09,"net":0.09,"unitCost":900},{"productId":"demo-v1-stock-blue","name":"Сыр с голубой плесенью","unit":"кг","gross":0.06,"net":0.06,"unitCost":1700},{"productId":"demo-v1-stock-grape","name":"Виноград","unit":"кг","gross":0.088,"net":0.08,"unitCost":420},{"productId":"demo-v1-stock-walnut","name":"Орех грецкий очищенный","unit":"кг","gross":0.025,"net":0.025,"unitCost":1000},{"productId":"demo-v1-stock-honey","name":"Мёд","unit":"кг","gross":0.02,"net":0.02,"unitCost":800},{"productId":"demo-v1-stock-cracker","name":"Крекер","unit":"кг","gross":0.025,"net":0.025,"unitCost":500}],"steps":["Приготовить две мини-подборки в половинном объёме по указанным нормам.","Мясное ассорти с корнишонами: Нарезать готовые мясные продукты тонкими ломтиками.","Мясное ассорти с корнишонами: Обсушить корнишоны и маслины, переложить горчицу в соусник.","Мясное ассорти с корнишонами: Разложить ассорти секциями, добавить гарниры.","Сырный бокс с виноградом и орехами: Нарезать сыры порционными кусочками.","Сырный бокс с виноградом и орехами: Подготовить виноград, переложить мёд в соусник.","Сырный бокс с виноградом и орехами: Разложить сыры, виноград, орехи и крекер отдельными секциями.","Уложить обе подборки в одну общую коробку."],"allergens":["Горчица","Молоко","Орехи","Глютен"],"ingredientCost":964.66,"note":"Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."}},{"id":"demo-extras-v1-mini-buffet","name":"Премиум-сет «Мини-фуршет»","category":5,"catalogSection":"Демонстрационные премиум-сеты","price":2900,"pieces":22,"weight":"820 г","photo":"","demo":true,"composition":["Половина стандартного бокса: Брускетты с томатами и базиликом","Половина стандартного бокса: Тарталетки с грибами и сыром"],"ingredients":[["Багет",0.24,"кг"],["Томаты",0.165,"кг"],["Базилик",0.013,"кг"],["Масло оливковое",0.028,"кг"],["Коробка для кейтеринга",1,"шт."],["Тарталетки готовые",0.1,"кг"],["Шампиньоны",0.22,"кг"],["Сливки",0.05,"кг"],["Сыр гауда",0.06,"кг"],["Лук репчатый",0.044,"кг"]],"ttk":{"number":"ДЕМО-П3","basis":"На 1 готовый премиум-сет","outputGrams":820,"pieces":22,"rows":[{"productId":"demo-v1-stock-baguette","name":"Багет","unit":"кг","gross":0.24,"net":0.24,"unitCost":240},{"productId":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","gross":0.165,"net":0.15,"unitCost":260},{"productId":"demo-v1-stock-basil","name":"Базилик","unit":"кг","gross":0.013,"net":0.012,"unitCost":1500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.028,"net":0.028,"unitCost":1100},{"productId":"demo-v1-stock-box","name":"Коробка для кейтеринга","unit":"шт.","gross":1,"net":1,"unitCost":65},{"productId":"demo-v1-stock-tartlet","name":"Тарталетки готовые","unit":"кг","gross":0.1,"net":0.1,"unitCost":750},{"productId":"demo-v1-stock-mushroom","name":"Шампиньоны","unit":"кг","gross":0.22,"net":0.15,"unitCost":340},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.05,"net":0.05,"unitCost":500},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.06,"net":0.06,"unitCost":900},{"productId":"demo-v1-stock-onion","name":"Лук репчатый","unit":"кг","gross":0.044,"net":0.03,"unitCost":80}],"steps":["Приготовить две мини-подборки в половинном объёме по указанным нормам.","Брускетты с томатами и базиликом: Нарезать багет на 24 одинаковых ломтика и подсушить.","Брускетты с томатами и базиликом: Подготовить томаты и базилик, смешать с маслом.","Брускетты с томатами и базиликом: Распределить начинку по ломтикам, уложить в коробку.","Тарталетки с грибами и сыром: Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.","Тарталетки с грибами и сыром: Добавить сливки и распределить начинку по 20 тарталеткам.","Тарталетки с грибами и сыром: Добавить сыр, запечь до расплавления и упаковать после охлаждения.","Уложить обе подборки в одну общую коробку."],"allergens":["Глютен","Молоко"],"ingredientCost":448.12,"note":"Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."}},{"id":"demo-extras-v1-water","name":"Вода негазированная, 0,5 л","category":3,"catalogSection":"Безалкогольные напитки","price":90,"pieces":1,"weight":"","photo":"","composition":["Одна бутылка 0,5 л"],"ingredients":[["Демо: вода негазированная 0,5 л",1,"шт."]],"demo":true},{"id":"demo-extras-v1-sparkling","name":"Вода газированная, 0,5 л","category":3,"catalogSection":"Безалкогольные напитки","price":100,"pieces":1,"weight":"","photo":"","composition":["Одна бутылка 0,5 л"],"ingredients":[["Демо: вода газированная 0,5 л",1,"шт."]],"demo":true},{"id":"demo-extras-v1-juice","name":"Яблочный сок, 1 л","category":3,"catalogSection":"Безалкогольные напитки","price":220,"pieces":1,"weight":"","photo":"","composition":["Одна упаковка 1 л"],"ingredients":[["Демо: сок яблочный 1 л",1,"шт."]],"demo":true},{"id":"demo-extras-v1-mors","name":"Клюквенный морс, 1 л","category":3,"catalogSection":"Безалкогольные напитки","price":280,"pieces":1,"weight":"","photo":"","composition":["Одна бутылка готового морса 1 л"],"ingredients":[["Демо: морс клюквенный 1 л",1,"шт."]],"demo":true},{"id":"demo-extras-v1-plate","name":"Тарелка одноразовая, 1 шт.","category":1,"catalogSection":"Одноразовая посуда","price":25,"pieces":1,"weight":"","photo":"","composition":["Одна сервировочная тарелка"],"ingredients":[["Демо: тарелка одноразовая",1,"шт."]],"demo":true},{"id":"demo-extras-v1-fork","name":"Вилка одноразовая, 1 шт.","category":1,"catalogSection":"Одноразовая посуда","price":10,"pieces":1,"weight":"","photo":"","composition":["Одна вилка"],"ingredients":[["Демо: вилка одноразовая",1,"шт."]],"demo":true},{"id":"demo-extras-v1-glass","name":"Стакан одноразовый, 250 мл","category":1,"catalogSection":"Одноразовая посуда","price":15,"pieces":1,"weight":"","photo":"","composition":["Один стакан"],"ingredients":[["Демо: стакан одноразовый 250 мл",1,"шт."]],"demo":true},{"id":"demo-extras-v1-napkin","name":"Салфетка сервировочная, 1 шт.","category":1,"catalogSection":"Одноразовая посуда","price":10,"pieces":1,"weight":"","photo":"","composition":["Одна бумажная салфетка"],"ingredients":[["Демо: салфетка сервировочная",1,"шт."]],"demo":true},{"id":"demo-extras-v1-ice","name":"Лёд пищевой, пакет 1 кг","category":2,"catalogSection":"Для сервировки","price":180,"pieces":1,"weight":"1 кг","photo":"","composition":["Один пакет пищевого льда, 1 кг"],"ingredients":[["Демо: лёд пищевой",1,"кг"]],"demo":true},{"id":"demo-extras-v1-tablecloth","name":"Скатерть одноразовая, 1 шт.","category":2,"catalogSection":"Для сервировки","price":250,"pieces":1,"weight":"","photo":"","composition":["Одна одноразовая скатерть 120 × 180 см"],"ingredients":[["Демо: скатерть одноразовая",1,"шт."]],"demo":true},{"id":"demo-extras-v1-serving-kit","name":"Набор для подачи: щипцы и лопатка","category":2,"catalogSection":"Для сервировки","price":190,"pieces":1,"weight":"","photo":"","composition":["Один комплект одноразовых приборов для подачи"],"ingredients":[["Демо: набор щипцы и лопатка",1,"шт."]],"demo":true},{"id":"demo-extras-v1-delivery-city","name":"Доставка по городу","category":4,"catalogSection":"Учебные тарифы доставки","price":600,"pieces":0,"weight":"","photo":"","composition":["Учебный тариф за один адрес в пределах города."],"ingredients":[],"demo":true},{"id":"demo-extras-v1-delivery-outer","name":"Доставка за город","category":4,"catalogSection":"Учебные тарифы доставки","price":1200,"pieces":0,"weight":"","photo":"","composition":["Учебный тариф за один адрес в ближайшем пригороде."],"ingredients":[],"demo":true}],"stock":[{"id":"demo-extras-v1-stock-water","name":"Демо: вода негазированная 0,5 л","cost":35,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-sparkling","name":"Демо: вода газированная 0,5 л","cost":40,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-juice","name":"Демо: сок яблочный 1 л","cost":110,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-mors","name":"Демо: морс клюквенный 1 л","cost":140,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-plate","name":"Демо: тарелка одноразовая","cost":12,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-fork","name":"Демо: вилка одноразовая","cost":4,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-glass","name":"Демо: стакан одноразовый 250 мл","cost":6,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-napkin","name":"Демо: салфетка сервировочная","cost":3,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-ice","name":"Демо: лёд пищевой","cost":70,"unit":"кг","qty":0,"min":1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-tablecloth","name":"Демо: скатерть одноразовая","cost":110,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-extras-v1-stock-serving-kit","name":"Демо: набор щипцы и лопатка","cost":85,"unit":"шт.","qty":0,"min":10,"supplierId":"demo-v1-supplier-pack","lastPurchaseDate":"","lastPurchasePrice":0}]}$demo$::jsonb $fn$; +revoke all on function public.caterium_trial_extras_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,'canUpgrade',coalesce(v_allowed,false) and v_installed and (coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1' or coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')<>'1')); +end $fn$; + + +create or replace function public.caterium_install_trial_extras(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_storage jsonb; v_seed jsonb; + v_catalog jsonb; v_stock jsonb; v_added jsonb; v_products 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 not (v_status->>'installed')::boolean then raise exception 'Сначала установите демонстрационную базу'; end if; + select * into v_state from public.sun_app_state where workspace_id=p_workspace for update; + v_storage:=v_state.payload->'storage'; + if coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')='1' then return v_status; end if; + v_catalog:=coalesce(v_storage#>'{sunBoxes,v}','[]'::jsonb);v_stock:=coalesce(v_storage#>'{sunStock,v}','[]'::jsonb); + if jsonb_typeof(v_catalog)<>'array' or jsonb_typeof(v_stock)<>'array' then raise exception 'Неверный формат каталога или склада. Данные сохранены без изменений'; end if; + v_seed:=public.caterium_trial_extras_seed_v1(); + select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_added + from jsonb_array_elements(v_seed->'items') with ordinality as x(n,ord) + where not exists(select 1 from jsonb_array_elements(v_catalog) old where coalesce(old->>'category','0')=n->>'category' or old->>'id'=n->>'id'); + select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_products + from jsonb_array_elements(v_seed->'stock') with ordinality as x(n,ord) + where not exists(select 1 from jsonb_array_elements(v_stock) old where old->>'id'=n->>'id' or (old->>'name'=n->>'name' and old->>'unit'=n->>'unit')) + and exists(select 1 from jsonb_array_elements(v_added) i cross join lateral jsonb_array_elements(i->'ingredients') r where r->>0=n->>'name' and r->>2=n->>'unit'); + v_marker:=(v_storage#>'{sunTrialDemoV1,v}')||jsonb_build_object('extrasVersion',1,'extrasInstalledAt',now()); + v_storage:=v_storage||jsonb_build_object('sunBoxes',jsonb_build_object('t','j','v',v_catalog||v_added),'sunStock',jsonb_build_object('t','j','v',v_stock||v_products),'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-extras-v1',v_state.revision); + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details) + values(auth.uid(),'trial_demo.extras_install',p_workspace,jsonb_build_object('version',1,'items',jsonb_array_length(v_added))); + return public.caterium_trial_demo_status(p_workspace); +end $fn$; +revoke all on function public.caterium_install_trial_extras(uuid) from public,anon; +grant execute on function public.caterium_install_trial_extras(uuid) to authenticated; + +create or replace function public.caterium_install_trial_banquet(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_storage jsonb; + v_seed jsonb; v_key text; v_items jsonb; v_added 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 not (v_status->>'installed')::boolean then raise exception 'Сначала установите демонстрационную базу'; end if; + if coalesce((select payload#>>'{storage,sunTrialDemoV1,v,banquetVersion}' from public.sun_app_state where workspace_id=p_workspace),'')='1' then return v_status; end if; + select * into v_state from public.sun_app_state where workspace_id=p_workspace for update; + v_storage:=v_state.payload->'storage'; v_seed:=public.caterium_trial_banquet_seed_v1(); + foreach v_key in array array['sunBoxes','sunStock'] loop + v_items:=coalesce(v_storage#>array[v_key,'v'],'[]'::jsonb); + if jsonb_typeof(v_items)<>'array' then raise exception 'Неверный формат каталога или склада. Данные сохранены без изменений'; end if; + -- Keep every existing row byte-for-byte, including edited demo prices and balances. + select coalesce(jsonb_agg(n),'[]'::jsonb) into v_added + from jsonb_array_elements(v_seed->case when v_key='sunBoxes' then 'banquet' else 'stock' end) n + where not exists(select 1 from jsonb_array_elements(v_items) old where old->>'id'=n->>'id'); + v_storage:=v_storage||jsonb_build_object(v_key,jsonb_build_object('t','j','v',v_items||v_added)); + end loop; + v_marker:=(v_storage#>'{sunTrialDemoV1,v}')||jsonb_build_object('banquetVersion',1,'banquetInstalledAt',now()); + v_storage:=jsonb_set(v_storage,'{sunTrialDemoV1,v}',v_marker); + perform public.sun_save_app_state_v17(p_workspace,jsonb_set(v_state.payload,'{storage}',v_storage),'trial-banquet-v1',v_state.revision); + insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details) + values(auth.uid(),'trial_demo.banquet_install',p_workspace,jsonb_build_object('version',1,'dishes',18)); + return public.caterium_trial_demo_status(p_workspace); +end $fn$; + +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 perform public.caterium_install_trial_banquet(p_workspace); return public.caterium_install_trial_extras(p_workspace); 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)); + perform public.caterium_install_trial_banquet(p_workspace); return public.caterium_install_trial_extras(p_workspace); +end $fn$; + +notify pgrst,'reload schema'; +commit; diff --git a/tests/recovery/smoke.mjs b/tests/recovery/smoke.mjs index 32c7487..f74ffa7 100644 --- a/tests/recovery/smoke.mjs +++ b/tests/recovery/smoke.mjs @@ -61,21 +61,23 @@ await actor(ids[2]);const foreign=(await one('select public.sun_create_workspace 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,28);assert.equal(training.sunStock.v.length,52);assert.equal(training.sunSuppliers.v.length,5); +assert.equal(training.sunBoxes.v.length,44);assert.equal(training.sunStock.v.length,63);assert.equal(training.sunSuppliers.v.length,5); +assert.deepEqual([0,1,2,3,4,5,6].map(c=>training.sunBoxes.v.filter(b=>b.category===c).length),[10,4,3,4,2,3,18]); assert.equal(training.sunBoxes.v.filter(b=>b.category===6).length,18);assert.equal(training.sunTrialDemoV1.v.banquetVersion,1); 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,28); +assert.equal((await one('select public.sun_v17_entity_snapshot($1) as s',[foreign])).s.catalog.length,44); 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,b.ttk.waterGrams||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));}} +for(const b of training.sunBoxes.v){for(const [name,qty,unit] of b.ingredients){assert.ok(qty>0);assert.ok(training.sunStock.v.some(p=>p.name===name&&p.unit===unit));}if(!b.ttk)continue;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,b.ttk.waterGrams||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'); // Model an already used v1 trial: no banquet yet, edited prices, real balances and an order. const legacy=structuredClone(demoRow.payload); -legacy.storage.sunBoxes.v=legacy.storage.sunBoxes.v.filter(b=>b.category!==6); +legacy.storage.sunBoxes.v=legacy.storage.sunBoxes.v.filter(b=>b.category===0); legacy.storage.sunBoxes.v[0].price=4321; -legacy.storage.sunStock.v=legacy.storage.sunStock.v.filter(p=>!p.id.startsWith('demo-banquet-')); +legacy.storage.sunStock.v=legacy.storage.sunStock.v.filter(p=>p.id.startsWith('demo-v1-stock-')); legacy.storage.sunStock.v[0].qty=7.123; delete legacy.storage.sunTrialDemoV1.v.banquetVersion; +delete legacy.storage.sunTrialDemoV1.v.extrasVersion; legacy.storage.sunOrders={t:'j',v:[{id:91,event:'Keep my order',date:'2099-12-20',time:'12:00',status:'Новый',lines:[],total:4321,prepayment:100,balance:4221,autoCompletionDisabled:true}]}; let upgraded=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,legacy,'legacy-trial-fixture',demoRow.revision]); assert.equal((await one('select public.caterium_trial_demo_status($1) as s',[foreign])).s.canUpgrade,true); @@ -84,18 +86,38 @@ upgraded=await one('select * from public.sun_fetch_app_state($1)',[foreign]); assert.deepEqual(upgraded.payload.storage.sunBoxes.v.slice(0,10),legacy.storage.sunBoxes.v); assert.deepEqual(upgraded.payload.storage.sunStock.v.slice(0,43),legacy.storage.sunStock.v); for(const key of ['sunOrders','sunStockMoves','sunSuppliers'])assert.deepEqual(upgraded.payload.storage[key],legacy.storage[key]); -assert.equal(upgraded.payload.storage.sunBoxes.v.length,28); +assert.equal(upgraded.payload.storage.sunBoxes.v.length,44); await q('select public.caterium_install_trial_banquet($1)',[foreign]); assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,upgraded.revision); +// Existing custom drink/ware tabs must remain untouched, with no unused demo stock. +const partial=structuredClone(upgraded.payload); +partial.storage.sunBoxes.v=partial.storage.sunBoxes.v.filter(b=>!b.id.startsWith('demo-extras-')); +partial.storage.sunStock.v=partial.storage.sunStock.v.filter(p=>!p.id.startsWith('demo-extras-')); +delete partial.storage.sunTrialDemoV1.v.extrasVersion; +const custom=[{id:'own-drink',name:'My drink',category:3,price:123,ingredients:[]},{id:'own-plate',name:'My plate',category:1,price:456,ingredients:[]}]; +partial.storage.sunBoxes.v.push(...custom); +await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,partial,'partly-filled-trial',upgraded.revision]); +await q('select public.caterium_install_trial_demo($1)',[foreign]); +upgraded=await one('select * from public.sun_fetch_app_state($1)',[foreign]); +assert.equal(upgraded.payload.storage.sunBoxes.v.length,38); +assert.deepEqual(upgraded.payload.storage.sunBoxes.v.filter(b=>b.category===1||b.category===3),custom); +assert.equal(upgraded.payload.storage.sunStock.v.length,55); +assert.deepEqual(upgraded.payload.storage.sunBoxes.v.slice(0,30),partial.storage.sunBoxes.v); +assert.deepEqual(upgraded.payload.storage.sunStock.v.slice(0,52),partial.storage.sunStock.v); +assert.equal(upgraded.payload.storage.sunTrialDemoV1.v.banquetInstalledAt,partial.storage.sunTrialDemoV1.v.banquetInstalledAt,'already upgraded banquet untouched'); +await q('select public.caterium_install_trial_extras($1)',[foreign]); +assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,upgraded.revision); const filled=structuredClone(upgraded.payload);delete filled.storage.sunTrialDemoV1; await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,filled,'test-existing-catalog',upgraded.revision]); await denied('select public.caterium_install_trial_banquet($1)',[foreign],/Сначала установите/); +await denied('select public.caterium_install_trial_extras($1)',[foreign],/Сначала установите/); 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 public.caterium_install_trial_banquet($1)',[ws],/Administrator membership/); +await denied('select public.caterium_install_trial_extras($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]); @@ -112,6 +134,7 @@ 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 denied('select public.caterium_install_trial_banquet($1)',[ws],/Демонстрационная база доступна/); +await denied('select public.caterium_install_trial_extras($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]]); @@ -122,7 +145,8 @@ 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,28,'developer own company works without developer-panel MFA'); +assert.equal(seeded.sunBoxes.v.length,44,'developer own company works without developer-panel MFA'); +await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_extras($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_extras_seed_v1()',[],/permission denied/); await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_banquet($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_banquet_seed_v1()',[],/permission denied/); 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 box + 18 banquet TTKs, stock links, additive upgrade preserving orders/prices/balances, idempotency, populated catalog guard, Solnce exclusion, own developer account and preserved clients'); diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 52d0be4..2db88d5 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('v97-20260918-trial-banquet')&&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-banquet')&&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('v98-20260918-trial-extras')&&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-extras')&&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('v97-20260918-trial-banquet'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v98-20260918-trial-extras'),'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=45000')&&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-trial-banquet'),'index cache bust is v17.7.3'); -check(sw.includes('v97-20260918-trial-banquet')&&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-extras'),'index cache bust is v17.7.3'); +check(sw.includes('v98-20260918-trial-extras')&&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 5b81916..5994a1a 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-trial-banquet')||!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-banquet')||!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-extras')||!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-extras')||!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 index fbce412..5a20746 100644 --- a/tests/trial-demo.spec.mjs +++ b/tests/trial-demo.spec.mjs @@ -2,7 +2,8 @@ import {test,expect} from '@playwright/test'; import fs from 'node:fs'; import {demo} from '../ops/demo/trial-demo-data.mjs'; import {banquetDemo} from '../ops/demo/trial-banquet-data.mjs'; -const completeDemo={...demo,boxes:[...demo.boxes,...banquetDemo.banquet],stock:[...demo.stock,...banquetDemo.stock]}; +import {extrasDemo} from '../ops/demo/trial-extras-data.mjs'; +const completeDemo={...demo,boxes:[...demo.boxes,...banquetDemo.banquet,...extrasDemo.items],stock:[...demo.stock,...banquetDemo.stock,...extrasDemo.stock]}; test('installed app serves old trial PNG links as cached lightweight images even offline',async({page,context})=>{ await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort()); @@ -87,6 +88,32 @@ test('trial banquet categories, per-guest TTK, shopping and stock work together' expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-banquet-v1-stock-potato').qty)).toBeCloseTo(.32,3); }); +test('trial extras populate every tab and supplies reach shopping and stock',async({page})=>{ + await ready(page); + for(const [label,count] of [['Премиум',3],['Напитки',4],['Посуда',4],['Дополнения',3],['Доставка',2]]){ + await page.getByRole('button',{name:label,exact:true}).click(); + await expect(page.locator('#tiles .tile:not(.add)')).toHaveCount(count); + } + await page.getByRole('button',{name:'Напитки',exact:true}).click(); + await page.locator('#tiles .tile').filter({hasText:'Вода негазированная'}).click(); + await page.locator('#tiles .tile').filter({hasText:'Вода негазированная'}).click(); + await page.getByRole('button',{name:'Посуда',exact:true}).click(); + await page.locator('#tiles .tile').filter({hasText:'Тарелка одноразовая'}).click(); + await page.getByRole('button',{name:'Доставка',exact:true}).click(); + await page.locator('#tiles .tile').filter({hasText:'Доставка по городу'}).click(); + expect(await page.evaluate(()=>window.sunBaseOrderTotal(draft))).toBe(805); + await page.evaluate(()=>{const day=new Date();day.setDate(day.getDate()+1);draft.event='Демо с напитками';draft.date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;draft.time='12:00';draft.autoCompletionDisabled=true;document.getElementById('date').value=draft.date;document.getElementById('time').value=draft.time;window.saveOrder()}); + await page.getByRole('button',{name:'Закупки',exact:true}).first().click(); + await expect(page.locator('[data-shop-key="демо: вода негазированная 0,5 л|шт."]')).toContainText('2'); + await expect(page.locator('[data-shop-key="демо: тарелка одноразовая|шт."]')).toContainText('1'); + expect((await page.locator('[data-shop-key]').allTextContents()).every(text=>!text.includes('Доставка по городу'))).toBe(true); + await page.getByRole('button',{name:'Склад',exact:true}).first().click(); + await page.evaluate(()=>window.stockMovement('demo-extras-v1-stock-water','in')); + await page.locator('#stockMoveQty').fill('5');await page.locator('#stockSaveMove').click(); + await page.evaluate(()=>window.writeOffOrderStock(orders.at(-1).id)); + expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-extras-v1-stock-water').qty)).toBe(3); +}); + 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'});