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'});