diff --git a/docs/release-manifest.json b/docs/release-manifest.json index 8e43e17..50088e9 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v96-20260918-single-login", + "pwaCache": "v97-20260918-trial-banquet", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, diff --git a/ops/demo/build-trial-banquet.mjs b/ops/demo/build-trial-banquet.mjs new file mode 100644 index 0000000..82447cb --- /dev/null +++ b/ops/demo/build-trial-banquet.mjs @@ -0,0 +1,18 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +import {banquetDemo} from './trial-banquet-data.mjs'; +const root=new URL('../../',import.meta.url); +const original=fs.readFileSync(new URL('trial-demo.sql',import.meta.url),'utf8'); +const functionBlock=name=>original.slice(original.indexOf(`create or replace function public.${name}(`),original.indexOf('end $fn$;',original.indexOf(`create or replace function public.${name}(`))+10); +let status=functionBlock('caterium_trial_demo_status'); +const oldStatus="'canInstall',coalesce(v_allowed,false) and v_empty and not v_installed"; +assert.ok(status.includes(oldStatus)); +status=status.replace(oldStatus,oldStatus+",'canUpgrade',coalesce(v_allowed,false) and v_installed and coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1'"); +let install=functionBlock('caterium_install_trial_demo'); +assert.ok(install.includes('then return v_status; end if;')); +install=install.replace('then return v_status; end if;','then return public.caterium_install_trial_banquet(p_workspace); end if;') + .replace('return public.caterium_trial_demo_status(p_workspace);','return public.caterium_install_trial_banquet(p_workspace);'); +const template=fs.readFileSync(new URL('trial-banquet.sql',import.meta.url),'utf8'); +fs.writeFileSync(new URL('public/demo/banquet-v1.json',root),JSON.stringify(banquetDemo,null,2)+'\n'); +fs.writeFileSync(new URL('supabase/migrations/20260918060000_trial_banquet.sql',root),template.replace('__BANQUET_JSON__',JSON.stringify(banquetDemo)).replace('__STATUS_FUNCTION__',status).replace('__INSTALL_FUNCTION__',install)); +console.log(`Built ${banquetDemo.banquet.length} banquet dishes and ${banquetDemo.stock.length} additional stock products.`); diff --git a/ops/demo/trial-banquet-data.mjs b/ops/demo/trial-banquet-data.mjs new file mode 100644 index 0000000..ed6432b --- /dev/null +++ b/ops/demo/trial-banquet-data.mjs @@ -0,0 +1,77 @@ +// Fictional one-portion recipes. Reuse the original demo stock without resetting it. +import {stock as boxStock} from './trial-demo-data.mjs'; +const extra=[ + ['potato','Картофель',80,'fresh'],['butter','Масло сливочное',1100,'fresh'], + ['milk','Молоко',110,'fresh'],['egg','Яйцо куриное без скорлупы',260,'fresh'], + ['peas','Горошек зелёный консервированный',300,'grocery'], + ['chicken-raw','Филе куриное сырое',480,'protein'],['whitefish','Филе трески',950,'protein'], + ['lemon','Лимон',300,'fresh'],['rice','Рис сухой',180,'grocery'] +].map(([key,name,cost,supplier])=>({id:`demo-banquet-v1-stock-${key}`,name,cost,unit:'кг',qty:0,min:0.1,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0})); +const products=new Map([...boxStock.map(p=>[p.id.replace('demo-v1-stock-',''),p]),...extra.map(p=>[p.id.replace('demo-banquet-v1-stock-',''),p])]); +// Gross / prepared net grams per guest; water absorbed by rice is described separately. +const recipes=[ + ['caprese','Капрезе с моцареллой и базиликом','Холодные закуски',290, + [['mozzarella',50,50],['tomato',70,60],['basil',6,5],['oil',5,5]],['Молоко'], + ['Нарезать томаты и моцареллу.','Выложить слоями, добавить базилик и масло.']], + ['roastbeef','Ростбиф с корнишонами и горчичным соусом','Холодные закуски',390, + [['beef',65,65],['pickle',22,20],['mustard',5,5],['cooking-cream',10,10]],['Молоко','Горчица'], + ['Нарезать готовый ростбиф тонкими ломтиками.','Смешать сливки с горчицей, подать с корнишонами.']], + ['salmon-roll','Рулетики из лосося со сливочным сыром','Холодные закуски',350, + [['salmon',45,45],['cream',25,25],['cucumber',22,18],['dill',3,2]],['Рыба','Молоко'], + ['Нарезать лосось и огурец тонкими ломтиками.','Распределить сыр, свернуть рулетики, украсить укропом.']], + ['hummus','Овощные палочки с хумусом','Холодные закуски',230, + [['carrot',48,40],['cucumber',44,40],['pepper',40,30],['hummus',50,50]],['Кунжут в составе хумуса'], + ['Очистить овощи и нарезать палочками.','Подать с порцией готового хумуса.']], + ['caesar','Салат с курицей, сухариками и сыром','Салаты',340, + [['chicken',60,60],['lettuce',42,35],['cherry',35,30],['toast',23,20],['gouda',15,15],['cooking-cream',15,15],['mustard',5,5]],['Глютен','Молоко','Горчица'], + ['Подсушить кубики хлеба, подготовить салат и томаты.','Нарезать готовую курицу, соединить с овощами и сыром.','Заправить сливками с горчицей, сверху добавить сухарики.']], + ['olivier','Оливье с индейкой','Салаты',280, + [['potato',65,50],['carrot',26,20],['turkey',35,35],['egg',22,20],['peas',20,20],['pickle',22,20],['cooking-cream',12,12],['mustard',3,3]],['Яйцо','Молоко','Горчица'], + ['Отварить картофель, морковь и яйцо; остудить.','Нарезать овощи, яйцо, ветчину и корнишоны кубиком.','Добавить горошек и заправить сливками с горчицей.']], + ['greek','Овощной салат с моцареллой и маслинами','Салаты',290, + [['tomato',55,50],['cucumber',44,40],['pepper',32,25],['mozzarella',40,40],['olive',17,15],['oil',10,10]],['Молоко'], + ['Подготовить и крупно нарезать овощи.','Добавить моцареллу и маслины, заправить маслом.']], + ['julienne','Жюльен с курицей и грибами','Горячие закуски',310, + [['chicken',40,40],['mushroom',60,40],['onion',15,10],['cooking-cream',25,20],['gouda',15,15],['oil',5,5]],['Молоко'], + ['Обжарить нарезанные грибы и лук на масле.','Добавить готовую курицу и сливки, распределить по формам.','Посыпать сыром и запечь до расплавления.']], + ['stuffed-mushrooms','Шампиньоны, запечённые с сыром','Горячие закуски',270, + [['mushroom',125,85],['cream',20,20],['gouda',10,10],['dill',3,2],['oil',3,3]],['Молоко'], + ['Подготовить шляпки грибов, ножки мелко нарезать.','Смешать ножки со сливочным сыром и укропом, наполнить шляпки.','Добавить гауду и запечь.']], + ['chicken','Куриное филе с овощами и сливочным соусом','Горячие блюда',490, + [['chicken-raw',180,135],['pepper',45,30],['carrot',32,25],['cooking-cream',30,25],['oil',5,5]],['Молоко'], + ['Обжарить куриное филе и довести до готовности.','Отдельно приготовить перец и морковь.','Добавить сливочный соус, подать с овощами; учесть потери при приготовлении.']], + ['cod','Треска с лимоном и овощами','Горячие блюда',590, + [['whitefish',170,130],['tomato',45,35],['pepper',30,20],['lemon',15,10],['oil',5,5]],['Рыба'], + ['Подготовить филе и нарезать овощи.','Запечь рыбу с овощами, лимоном и маслом до готовности.']], + ['beef-hot','Ростбиф с грибами в сливочном соусе','Горячие блюда',690, + [['beef',125,120],['mushroom',80,55],['onion',24,15],['cooking-cream',30,25],['oil',5,5]],['Молоко'], + ['Обжарить грибы и лук, добавить сливки.','Прогреть готовый ростбиф и подать с грибным соусом.']], + ['mash','Картофельное пюре','Гарниры',160, + [['potato',170,120],['milk',25,20],['butter',10,10]],['Молоко'], + ['Очистить и отварить картофель, слить воду.','Размять с тёплым молоком и сливочным маслом.']], + ['rice','Рис с овощами','Гарниры',170, + [['rice',40,40],['carrot',25,20],['pepper',25,20],['peas',15,15],['oil',5,5]],[], + ['Отварить рис; учебный расчёт учитывает 50 г поглощённой воды на порцию.','Приготовить овощи, добавить горошек и смешать с рисом.'],50], + ['berry-cream','Ягодный десерт со сливочным кремом','Десерты',240, + [['cream',35,35],['cooking-cream',20,20],['biscuit',15,15],['strawberry',28,25],['blueberry',11,10],['sugar',5,5]],['Молоко','Глютен','Яйцо в составе бисквита'], + ['Смешать сыр, сливки и сахарную пудру.','Выложить в креманку бисквит, крем и подготовленные ягоды.']], + ['cheese-honey','Бри с виноградом, мёдом и орехами','Десерты',310, + [['brie',45,45],['grape',39,35],['walnut',10,10],['honey',10,10]],['Молоко','Орехи'], + ['Нарезать бри и подготовить виноград.','Подать с грецким орехом и мёдом.']], + ['fruit','Фруктово-ягодное ассорти','Фрукты и ягоды',290, + [['grape',88,80],['strawberry',77,70],['blueberry',32,30]],[], + ['Подготовить ягоды и виноград, удалить плодоножки.','Выложить порционное ассорти.']], + ['bread','Хлебная корзинка с зелёным маслом','Хлеб и масло',120, + [['baguette',30,30],['rye',20,20],['butter',8,8],['dill',3,2]],['Глютен','Молоко'], + ['Нарезать хлеб, при желании подсушить.','Смешать масло с укропом, подать отдельно.']] +]; +export const banquet=recipes.map(([key,name,catalogSection,price,quantities,allergens,steps,waterGrams=0],index)=>{ + const rows=quantities.map(([key,gross,net])=>{const p=products.get(key);return {productId:p.id,name:p.name,unit:p.unit,gross:gross/1000,net:net/1000,unitCost:p.cost}}); + const outputGrams=Math.round(rows.reduce((sum,r)=>sum+r.net*1000,waterGrams)); + return {id:`demo-banquet-v1-${key}`,name,category:6,catalogSection,price,weight:`${outputGrams} г`,pieces:1,photo:'',demo:true, + composition:[rows.map(r=>r.name).join(', ')],ingredients:rows.map(r=>[r.name,r.gross,r.unit]), + ttk:{number:`ДЕМО-Б${String(index+1).padStart(2,'0')}`,basis:'На 1 порцию / 1 гостя',outputGrams,waterGrams,pieces:1,rows,steps,allergens, + ingredientCost:Math.round(rows.reduce((sum,r)=>sum+r.gross*r.unitCost,0)*100)/100, + note:'Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены.'}}; +}); +export const banquetDemo={version:1,banquet,stock:extra}; diff --git a/ops/demo/trial-banquet.sql b/ops/demo/trial-banquet.sql new file mode 100644 index 0000000..0415895 --- /dev/null +++ b/ops/demo/trial-banquet.sql @@ -0,0 +1,47 @@ +-- Generated by ops/demo/build-trial-banquet.mjs. Additive demo upgrade only. +begin; +create or replace function public.caterium_trial_banquet_seed_v1() +returns jsonb language sql immutable security definer set search_path=public,pg_temp +as $fn$ select $demo$__BANQUET_JSON__$demo$::jsonb $fn$; +revoke all on function public.caterium_trial_banquet_seed_v1() from public,anon,authenticated; + +__STATUS_FUNCTION__ + +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 not (v_status->>'canUpgrade')::boolean 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$; +revoke all on function public.caterium_install_trial_banquet(uuid) from public,anon; +grant execute on function public.caterium_install_trial_banquet(uuid) to authenticated; + +-- Both onboarding's existing trigger and the install button use this entry point. +__INSTALL_FUNCTION__ +notify pgrst,'reload schema'; +commit; diff --git a/package.json b/package.json index 1bc3285..04a8bc4 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" + "build:demo": "node ops/demo/build-trial-demo.mjs && node ops/demo/build-trial-banquet.mjs" }, "devDependencies": { "@electric-sql/pglite": "0.5.8", diff --git a/public/core/banquet-menu.js b/public/core/banquet-menu.js index 24f886a..f6ede83 100644 --- a/public/core/banquet-menu.js +++ b/public/core/banquet-menu.js @@ -1,11 +1,11 @@ (()=>{ 'use strict'; - const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; + const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['sides','Гарниры'],['desserts','Десерты'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; let packageId='all',groupId='all'; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const money=v=>Math.round(Number(v)||0).toLocaleString('ru-RU')+' ₽'; const grams=item=>{const value=String(item?.weight||'').replace(',','.');return Math.round((parseFloat(value)||0)*(/кг/i.test(value)?1000:1))}; - const group=item=>{const s=String(item?.catalogSection||'').toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'}; + const group=item=>{const s=String(item?.catalogSection||'').toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/гарнир/.test(s)?'sides':/десерт|сладк/.test(s)?'desserts':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'}; function packages(catalog){const result=new Map();for(const i of catalog){const b=i.banquet;if(b?.packageId&&!result.has(b.packageId))result.set(b.packageId,{id:b.packageId,name:b.packageName||b.packageId,price:b.packagePrice,grams:b.packageGrams})}return [...result.values()]} function selection(draft,catalog){if(!Array.isArray(draft.banquetSelection)){const ids=new Set(catalog.map(i=>String(i.id)));draft.banquetSelection=(draft.lines||[]).filter(l=>ids.has(String(l.id))).map(l=>String(l.id))}return new Set(draft.banquetSelection.map(String))} const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1)); @@ -25,10 +25,11 @@ return `
Банкетное меню

Соберите меню для гостей

Выберите готовое меню или отметьте отдельные блюда.

${menus.length?`
${menus.map(m=>``).join('')}
`:''} + ${catalog.some(i=>i.demo&&i.ttk)?'

Учебные блюда: вес и цена указаны на одну порцию. Откройте ТТК, чтобы посмотреть состав и расход продуктов для склада и закупки.

':''} ${estimates?'

≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.

':''} ${menu?`
${esc(menu.name)} · горячее — одно блюдо на выбор
`:''}
${available.map(([id,label])=>``).join('')}
-
${visible.length?[...sections].map(([section,rows])=>`

${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'Одно блюдо на выбор в каждом пакете':''}

${rows.map(i=>`
`).join('')}
`).join(''):`

${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}

`}
+
${visible.length?[...sections].map(([section,rows])=>`

${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'Одно блюдо на выбор в каждом пакете':''}

${rows.map(i=>`
${i.demo&&i.ttk?``:''}
`).join('')}
`).join(''):`

${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}

`}
`; } function bind(root,context){ @@ -36,6 +37,7 @@ root.querySelectorAll('[data-banquet-package]').forEach(b=>b.onclick=()=>{packageId=b.dataset.banquetPackage;groupId='all';rerender()}); root.querySelectorAll('[data-banquet-group]').forEach(b=>b.onclick=()=>{groupId=b.dataset.banquetGroup;rerender()}); root.querySelectorAll('[data-banquet-item]').forEach(b=>b.onchange=()=>{toggle(draft,catalog,b.dataset.banquetItem);rerender()}); + root.querySelectorAll('[data-banquet-ttk]').forEach(b=>b.onclick=()=>window.CateriumTrialDemo?.showTTK(b.dataset.banquetTtk)); root.querySelectorAll('[data-banquet-edit]').forEach(b=>b.onclick=()=>window.editBox?.(b.dataset.banquetEdit)); const preset=root.querySelector('[data-banquet-preset]');if(preset)preset.onclick=()=>{chooseMenu(draft,catalog,preset.dataset.banquetPreset);rerender()}; const clear=root.querySelector('[data-banquet-clear]');if(clear)clear.onclick=()=>{draft.banquetSelection=[];rerender()}; @@ -51,12 +53,12 @@ document.getElementById('ctBanquetPackage').value=item?.banquet?.packageName||''; document.getElementById('ctBanquetEstimated').checked=item?.banquet?.estimated===true; document.getElementById('ctBanquetPackageNames').innerHTML=packages(catalog).map(m=>``).join(''); - let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Фрукты и ягоды','Хлеб и масло'].map(s=>``).join('');extra.appendChild(list)} + let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Гарниры','Десерты','Фрукты и ягоды','Хлеб и масло'].map(s=>``).join('');extra.appendChild(list)} document.getElementById('banquetSection')?.setAttribute('list',list.id); } function saveEditor(item,catalog){if(Number(item.category)!==6)return;const input=document.getElementById('ctBanquetPackage');if(!input)return;const name=input.value.trim(),known=packages(catalog).find(m=>m.name===name),same=item.banquet?.packageName===name;item.banquet={...(same?item.banquet:{}),packageId:name?(known?.id||'custom-'+name.toLowerCase().replace(/\s+/g,'-')):'',packageName:name,estimated:document.getElementById('ctBanquetEstimated')?.checked===true};if(known){item.banquet.packagePrice=known.price;item.banquet.packageGrams=known.grams}} const style=document.createElement('style');style.textContent=` - #tiles .ct-banquet{grid-column:1/-1;min-width:0;width:100%;color:#34352e}.ct-banquet-intro{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:6px 0 20px}.ct-eyebrow{font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#8b775c}.ct-banquet h2{font-size:24px;margin:6px 0}.ct-banquet-intro p{font-size:12px;color:#7a7d75;margin:0}.ct-banquet-intro button{white-space:nowrap;font-size:12px}.ct-banquet-packages{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.ct-banquet-packages button{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:14px 12px;border:1px solid #e3e1d9;border-radius:12px;background:#fff;color:#3e4238;text-align:left;min-width:0}.ct-banquet-packages b{font-size:14px}.ct-banquet-packages span{font-size:20px;font-weight:700}.ct-banquet-packages small{font-size:11px;color:#777c70}.ct-banquet-packages button[aria-pressed=true]{background:#edf1e7;border:1.5px solid #667956;box-shadow:0 3px 12px #3344250a}.ct-banquet-estimate{font-size:11px;line-height:1.5;color:#81735f;margin:12px 0 18px}.ct-banquet-preset{display:flex;gap:10px;align-items:center;justify-content:space-between;background:#f6f4ed;border-radius:10px;padding:10px 12px;font-size:12px;margin:14px 0}.ct-banquet-preset button{font-size:11px;white-space:nowrap}.ct-banquet-groups{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.ct-banquet-groups button{border:1px solid #e5e4df;border-radius:20px;padding:8px 10px;background:white;color:#707467;font-size:11px;font-weight:600}.ct-banquet-groups button[aria-pressed=true]{background:#3c4933;border-color:#3c4933;color:white}.ct-banquet-groups span{opacity:.65;margin-left:4px}.ct-banquet-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start}.ct-banquet-dishes{min-width:0}.ct-banquet-section{margin-bottom:20px}.ct-banquet-section h3{font-size:12px;color:#7b806e;font-weight:700;letter-spacing:.025em;margin:0 0 8px;padding-top:8px}.ct-banquet-section h3 small{display:block;font-size:10px;font-weight:400;letter-spacing:0;margin-top:4px}.ct-banquet-dish{display:flex;align-items:center;border:1px solid #ecebe6;border-radius:10px;margin:6px 0;padding:10px 8px;background:#fff;gap:3px}.ct-banquet-dish.selected{border-color:#b0bd9e;background:#f6f8f1}.ct-banquet-dish label{display:grid;grid-template-columns:17px minmax(0,1fr) 46px 67px;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer;font-size:12px;color:#3e4439}.ct-banquet-dish input{accent-color:#65794c;width:16px;height:16px;margin:0}.ct-banquet-name{line-height:1.4;font-weight:600;overflow-wrap:anywhere}.ct-banquet-name small{display:block;color:#9a9d90;font-size:10px;font-weight:400;margin-top:3px}.ct-banquet-weight{font-size:11px;color:#8a8e82;text-align:right;white-space:nowrap}.ct-banquet-price{text-align:right;white-space:nowrap;font-size:12px}.ct-banquet-dish>button{border:0;background:none;color:#8c947f;padding:5px;font-size:14px}.ct-banquet-summary{border:1px solid #e4e4d9;border-radius:14px;background:#fcfcf8;padding:17px;min-width:0}.ct-banquet-summary h3{font-size:22px;margin:6px 0 18px}.ct-banquet-summary label{font-size:11px;gap:7px}.ct-banquet-summary input{width:100%;border-radius:7px;font-size:15px;background:white}.ct-banquet-metric,.ct-banquet-total{display:grid;grid-template-columns:1fr auto;gap:7px;margin:18px 0}.ct-banquet-metric span,.ct-banquet-total span{font-size:12px;color:#767e6a}.ct-banquet-metric small,.ct-banquet-total small{grid-column:2;text-align:right;font-size:12px;color:#8c927f}.ct-banquet-total{border-top:1px solid #e3e4d9;padding-top:14px}.ct-banquet-total b{font-size:19px}.ct-banquet-summary .primary{width:100%;font-size:12px;background:#43543a;border-color:#43543a;line-height:1.4}.ct-banquet-summary .primary:disabled{opacity:.4;cursor:default}.ct-banquet-help{display:block;font-size:11px;line-height:1.5;color:#919581;margin-top:10px}.ct-banquet-summary details{font-size:11px;line-height:1.5;margin-top:15px}.ct-banquet-summary li{margin:6px 0}.ct-banquet-summary ul{padding-left:16px}.ct-banquet-clear{border:0;background:none;color:#92977f;font-size:11px;padding:12px 0 0}.ct-banquet-estimate-check{flex-direction:row!important;align-items:center;gap:8px;margin-top:12px}.ct-banquet-estimate-check input{width:16px;height:16px}.ct-banquet-summary:focus-within{outline:none} + #tiles .ct-banquet{grid-column:1/-1;min-width:0;width:100%;color:#34352e}.ct-banquet-intro{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:6px 0 20px}.ct-eyebrow{font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#8b775c}.ct-banquet h2{font-size:24px;margin:6px 0}.ct-banquet-intro p{font-size:12px;color:#7a7d75;margin:0}.ct-banquet-intro button{white-space:nowrap;font-size:12px}.ct-banquet-packages{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.ct-banquet-packages button{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:14px 12px;border:1px solid #e3e1d9;border-radius:12px;background:#fff;color:#3e4238;text-align:left;min-width:0}.ct-banquet-packages b{font-size:14px}.ct-banquet-packages span{font-size:20px;font-weight:700}.ct-banquet-packages small{font-size:11px;color:#777c70}.ct-banquet-packages button[aria-pressed=true]{background:#edf1e7;border:1.5px solid #667956;box-shadow:0 3px 12px #3344250a}.ct-banquet-estimate{font-size:11px;line-height:1.5;color:#81735f;margin:12px 0 18px}.ct-banquet-preset{display:flex;gap:10px;align-items:center;justify-content:space-between;background:#f6f4ed;border-radius:10px;padding:10px 12px;font-size:12px;margin:14px 0}.ct-banquet-preset button{font-size:11px;white-space:nowrap}.ct-banquet-groups{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.ct-banquet-groups button{border:1px solid #e5e4df;border-radius:20px;padding:8px 10px;background:white;color:#707467;font-size:11px;font-weight:600}.ct-banquet-groups button[aria-pressed=true]{background:#3c4933;border-color:#3c4933;color:white}.ct-banquet-groups span{opacity:.65;margin-left:4px}.ct-banquet-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start}.ct-banquet-dishes{min-width:0}.ct-banquet-section{margin-bottom:20px}.ct-banquet-section h3{font-size:12px;color:#7b806e;font-weight:700;letter-spacing:.025em;margin:0 0 8px;padding-top:8px}.ct-banquet-section h3 small{display:block;font-size:10px;font-weight:400;letter-spacing:0;margin-top:4px}.ct-banquet-dish{display:flex;align-items:center;border:1px solid #ecebe6;border-radius:10px;margin:6px 0;padding:10px 8px;background:#fff;gap:3px}.ct-banquet-dish.selected{border-color:#b0bd9e;background:#f6f8f1}.ct-banquet-dish label{display:grid;grid-template-columns:17px minmax(0,1fr) 46px 67px;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer;font-size:12px;color:#3e4439}.ct-banquet-dish input{accent-color:#65794c;width:16px;height:16px;margin:0}.ct-banquet-name{line-height:1.4;font-weight:600;overflow-wrap:anywhere}.ct-banquet-name small{display:block;color:#9a9d90;font-size:10px;font-weight:400;margin-top:3px}.ct-banquet-weight{font-size:11px;color:#8a8e82;text-align:right;white-space:nowrap}.ct-banquet-price{text-align:right;white-space:nowrap;font-size:12px}.ct-banquet-dish>button{border:0;background:none;color:#8c947f;padding:5px;font-size:14px}.ct-banquet-dish>[data-banquet-ttk]{font-size:10px;font-weight:700}.ct-banquet-summary{border:1px solid #e4e4d9;border-radius:14px;background:#fcfcf8;padding:17px;min-width:0}.ct-banquet-summary h3{font-size:22px;margin:6px 0 18px}.ct-banquet-summary label{font-size:11px;gap:7px}.ct-banquet-summary input{width:100%;border-radius:7px;font-size:15px;background:white}.ct-banquet-metric,.ct-banquet-total{display:grid;grid-template-columns:1fr auto;gap:7px;margin:18px 0}.ct-banquet-metric span,.ct-banquet-total span{font-size:12px;color:#767e6a}.ct-banquet-metric small,.ct-banquet-total small{grid-column:2;text-align:right;font-size:12px;color:#8c927f}.ct-banquet-total{border-top:1px solid #e3e4d9;padding-top:14px}.ct-banquet-total b{font-size:19px}.ct-banquet-summary .primary{width:100%;font-size:12px;background:#43543a;border-color:#43543a;line-height:1.4}.ct-banquet-summary .primary:disabled{opacity:.4;cursor:default}.ct-banquet-help{display:block;font-size:11px;line-height:1.5;color:#919581;margin-top:10px}.ct-banquet-summary details{font-size:11px;line-height:1.5;margin-top:15px}.ct-banquet-summary li{margin:6px 0}.ct-banquet-summary ul{padding-left:16px}.ct-banquet-clear{border:0;background:none;color:#92977f;font-size:11px;padding:12px 0 0}.ct-banquet-estimate-check{flex-direction:row!important;align-items:center;gap:8px;margin-top:12px}.ct-banquet-estimate-check input{width:16px;height:16px}.ct-banquet-summary:focus-within{outline:none} @container (min-width:760px){.ct-banquet-layout{grid-template-columns:minmax(0,1fr) 230px}.ct-banquet-summary{position:sticky;top:12px}} #new.sun-banquet-active .catalog{container-type:inline-size;width:100%;min-width:0} #new.sun-banquet-active #sunCatalogSearchV1775,#new.sun-banquet-active .sun-catalog-view-switch{display:none!important} diff --git a/public/core/performance.js b/public/core/performance.js index 7612ec1..807f607 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-single-login'; + const RELEASE='20260918-trial-banquet'; 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 0f92574..cad38ee 100644 --- a/public/core/trial-demo.js +++ b/public/core/trial-demo.js @@ -32,13 +32,14 @@ const items=catalog().filter(b=>b.demo&&b.ttk),active=installed()&&items.length>0; if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;} let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);} - const signature=JSON.stringify({active,items:items.map(x=>[x.id,x.name]),can:writable()}); + const signature=JSON.stringify({active,items:items.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.length} боксов с фото и ТТК · учебные остатки и поставщики

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

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

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

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

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

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

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

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

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

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

`; card.querySelector('[data-demo-install]')?.addEventListener('click',install); card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder); card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value)); @@ -47,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;} } @@ -72,11 +73,12 @@ $('ctDemoTtk')?.remove();const t=box.ttk,dialog=document.createElement('dialog');dialog.id='ctDemoTtk'; const changed=JSON.stringify(box.ingredients)!==JSON.stringify(t.rows.map(r=>[r.name,r.gross,r.unit])); dialog.innerHTML=`
${esc(t.number)} · ${esc(t.basis)}

${esc(box.name)}

-

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

+

${changed?'Исходный выход':'Выход'}: ${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''} · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: ${money(box.price)}

${changed?'

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

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

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

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

-

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

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

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

${esc(t.note)}

+ ${t.waterGrams?`

В выходе учтено ${num(t.waterGrams)} г воды, поглощённой при приготовлении. Вода не списывается со склада.

`:''} +

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

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

Аллергены: ${t.allergens.length?t.allergens.map(esc).join(', '):'в учебном составе не указаны; уточните по продуктам поставщика'}.

${esc(t.note)}

`; document.body.append(dialog);dialog.querySelectorAll('[data-ttk-close]').forEach(b=>b.onclick=()=>dialog.close()); dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)}; diff --git a/public/demo/banquet-v1.json b/public/demo/banquet-v1.json new file mode 100644 index 0000000..6089894 --- /dev/null +++ b/public/demo/banquet-v1.json @@ -0,0 +1,1873 @@ +{ + "version": 1, + "banquet": [ + { + "id": "demo-banquet-v1-caprese", + "name": "Капрезе с моцареллой и базиликом", + "category": 6, + "catalogSection": "Холодные закуски", + "price": 290, + "weight": "120 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Моцарелла мини, Томаты, Базилик, Масло оливковое" + ], + "ingredients": [ + [ + "Моцарелла мини", + 0.05, + "кг" + ], + [ + "Томаты", + 0.07, + "кг" + ], + [ + "Базилик", + 0.006, + "кг" + ], + [ + "Масло оливковое", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б01", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 120, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-mozzarella", + "name": "Моцарелла мини", + "unit": "кг", + "gross": 0.05, + "net": 0.05, + "unitCost": 950 + }, + { + "productId": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "gross": 0.07, + "net": 0.06, + "unitCost": 260 + }, + { + "productId": "demo-v1-stock-basil", + "name": "Базилик", + "unit": "кг", + "gross": 0.006, + "net": 0.005, + "unitCost": 1500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 1100 + } + ], + "steps": [ + "Нарезать томаты и моцареллу.", + "Выложить слоями, добавить базилик и масло." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 80.2, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-roastbeef", + "name": "Ростбиф с корнишонами и горчичным соусом", + "category": 6, + "catalogSection": "Холодные закуски", + "price": 390, + "weight": "100 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Ростбиф готовый, Корнишоны, Горчица, Сливки" + ], + "ingredients": [ + [ + "Ростбиф готовый", + 0.065, + "кг" + ], + [ + "Корнишоны", + 0.022, + "кг" + ], + [ + "Горчица", + 0.005, + "кг" + ], + [ + "Сливки", + 0.01, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б02", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 100, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-beef", + "name": "Ростбиф готовый", + "unit": "кг", + "gross": 0.065, + "net": 0.065, + "unitCost": 2000 + }, + { + "productId": "demo-v1-stock-pickle", + "name": "Корнишоны", + "unit": "кг", + "gross": 0.022, + "net": 0.02, + "unitCost": 400 + }, + { + "productId": "demo-v1-stock-mustard", + "name": "Горчица", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 350 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.01, + "net": 0.01, + "unitCost": 500 + } + ], + "steps": [ + "Нарезать готовый ростбиф тонкими ломтиками.", + "Смешать сливки с горчицей, подать с корнишонами." + ], + "allergens": [ + "Молоко", + "Горчица" + ], + "ingredientCost": 145.55, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-salmon-roll", + "name": "Рулетики из лосося со сливочным сыром", + "category": 6, + "catalogSection": "Холодные закуски", + "price": 350, + "weight": "90 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп" + ], + "ingredients": [ + [ + "Лосось слабосолёный", + 0.045, + "кг" + ], + [ + "Сыр сливочный", + 0.025, + "кг" + ], + [ + "Огурцы", + 0.022, + "кг" + ], + [ + "Укроп", + 0.003, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б03", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 90, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-salmon", + "name": "Лосось слабосолёный", + "unit": "кг", + "gross": 0.045, + "net": 0.045, + "unitCost": 2400 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.025, + "net": 0.025, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "gross": 0.022, + "net": 0.018, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-dill", + "name": "Укроп", + "unit": "кг", + "gross": 0.003, + "net": 0.002, + "unitCost": 700 + } + ], + "steps": [ + "Нарезать лосось и огурец тонкими ломтиками.", + "Распределить сыр, свернуть рулетики, украсить укропом." + ], + "allergens": [ + "Рыба", + "Молоко" + ], + "ingredientCost": 136.19, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-hummus", + "name": "Овощные палочки с хумусом", + "category": 6, + "catalogSection": "Холодные закуски", + "price": 230, + "weight": "160 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Морковь, Огурцы, Перец сладкий, Хумус готовый" + ], + "ingredients": [ + [ + "Морковь", + 0.048, + "кг" + ], + [ + "Огурцы", + 0.044, + "кг" + ], + [ + "Перец сладкий", + 0.04, + "кг" + ], + [ + "Хумус готовый", + 0.05, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б04", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 160, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-carrot", + "name": "Морковь", + "unit": "кг", + "gross": 0.048, + "net": 0.04, + "unitCost": 90 + }, + { + "productId": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "gross": 0.044, + "net": 0.04, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.04, + "net": 0.03, + "unitCost": 380 + }, + { + "productId": "demo-v1-stock-hummus", + "name": "Хумус готовый", + "unit": "кг", + "gross": 0.05, + "net": 0.05, + "unitCost": 600 + } + ], + "steps": [ + "Очистить овощи и нарезать палочками.", + "Подать с порцией готового хумуса." + ], + "allergens": [ + "Кунжут в составе хумуса" + ], + "ingredientCost": 59.2, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-caesar", + "name": "Салат с курицей, сухариками и сыром", + "category": 6, + "catalogSection": "Салаты", + "price": 340, + "weight": "180 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица" + ], + "ingredients": [ + [ + "Филе курицы готовое", + 0.06, + "кг" + ], + [ + "Салат листовой", + 0.042, + "кг" + ], + [ + "Томаты черри", + 0.035, + "кг" + ], + [ + "Хлеб тостовый", + 0.023, + "кг" + ], + [ + "Сыр гауда", + 0.015, + "кг" + ], + [ + "Сливки", + 0.015, + "кг" + ], + [ + "Горчица", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б05", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 180, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-chicken", + "name": "Филе курицы готовое", + "unit": "кг", + "gross": 0.06, + "net": 0.06, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-lettuce", + "name": "Салат листовой", + "unit": "кг", + "gross": 0.042, + "net": 0.035, + "unitCost": 700 + }, + { + "productId": "demo-v1-stock-cherry", + "name": "Томаты черри", + "unit": "кг", + "gross": 0.035, + "net": 0.03, + "unitCost": 450 + }, + { + "productId": "demo-v1-stock-toast", + "name": "Хлеб тостовый", + "unit": "кг", + "gross": 0.023, + "net": 0.02, + "unitCost": 240 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.015, + "net": 0.015, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.015, + "net": 0.015, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-mustard", + "name": "Горчица", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 350 + } + ], + "steps": [ + "Подсушить кубики хлеба, подготовить салат и томаты.", + "Нарезать готовую курицу, соединить с овощами и сыром.", + "Заправить сливками с горчицей, сверху добавить сухарики." + ], + "allergens": [ + "Глютен", + "Молоко", + "Горчица" + ], + "ingredientCost": 124.42, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-olivier", + "name": "Оливье с индейкой", + "category": 6, + "catalogSection": "Салаты", + "price": 280, + "weight": "180 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица" + ], + "ingredients": [ + [ + "Картофель", + 0.065, + "кг" + ], + [ + "Морковь", + 0.026, + "кг" + ], + [ + "Ветчина из индейки", + 0.035, + "кг" + ], + [ + "Яйцо куриное без скорлупы", + 0.022, + "кг" + ], + [ + "Горошек зелёный консервированный", + 0.02, + "кг" + ], + [ + "Корнишоны", + 0.022, + "кг" + ], + [ + "Сливки", + 0.012, + "кг" + ], + [ + "Горчица", + 0.003, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б06", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 180, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-banquet-v1-stock-potato", + "name": "Картофель", + "unit": "кг", + "gross": 0.065, + "net": 0.05, + "unitCost": 80 + }, + { + "productId": "demo-v1-stock-carrot", + "name": "Морковь", + "unit": "кг", + "gross": 0.026, + "net": 0.02, + "unitCost": 90 + }, + { + "productId": "demo-v1-stock-turkey", + "name": "Ветчина из индейки", + "unit": "кг", + "gross": 0.035, + "net": 0.035, + "unitCost": 1000 + }, + { + "productId": "demo-banquet-v1-stock-egg", + "name": "Яйцо куриное без скорлупы", + "unit": "кг", + "gross": 0.022, + "net": 0.02, + "unitCost": 260 + }, + { + "productId": "demo-banquet-v1-stock-peas", + "name": "Горошек зелёный консервированный", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 300 + }, + { + "productId": "demo-v1-stock-pickle", + "name": "Корнишоны", + "unit": "кг", + "gross": 0.022, + "net": 0.02, + "unitCost": 400 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.012, + "net": 0.012, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-mustard", + "name": "Горчица", + "unit": "кг", + "gross": 0.003, + "net": 0.003, + "unitCost": 350 + } + ], + "steps": [ + "Отварить картофель, морковь и яйцо; остудить.", + "Нарезать овощи, яйцо, ветчину и корнишоны кубиком.", + "Добавить горошек и заправить сливками с горчицей." + ], + "allergens": [ + "Яйцо", + "Молоко", + "Горчица" + ], + "ingredientCost": 70.11, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-greek", + "name": "Овощной салат с моцареллой и маслинами", + "category": 6, + "catalogSection": "Салаты", + "price": 290, + "weight": "180 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое" + ], + "ingredients": [ + [ + "Томаты", + 0.055, + "кг" + ], + [ + "Огурцы", + 0.044, + "кг" + ], + [ + "Перец сладкий", + 0.032, + "кг" + ], + [ + "Моцарелла мини", + 0.04, + "кг" + ], + [ + "Маслины без косточек", + 0.017, + "кг" + ], + [ + "Масло оливковое", + 0.01, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б07", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 180, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "gross": 0.055, + "net": 0.05, + "unitCost": 260 + }, + { + "productId": "demo-v1-stock-cucumber", + "name": "Огурцы", + "unit": "кг", + "gross": 0.044, + "net": 0.04, + "unitCost": 220 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.032, + "net": 0.025, + "unitCost": 380 + }, + { + "productId": "demo-v1-stock-mozzarella", + "name": "Моцарелла мини", + "unit": "кг", + "gross": 0.04, + "net": 0.04, + "unitCost": 950 + }, + { + "productId": "demo-v1-stock-olive", + "name": "Маслины без косточек", + "unit": "кг", + "gross": 0.017, + "net": 0.015, + "unitCost": 650 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.01, + "net": 0.01, + "unitCost": 1100 + } + ], + "steps": [ + "Подготовить и крупно нарезать овощи.", + "Добавить моцареллу и маслины, заправить маслом." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 96.19, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-julienne", + "name": "Жюльен с курицей и грибами", + "category": 6, + "catalogSection": "Горячие закуски", + "price": 310, + "weight": "130 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое" + ], + "ingredients": [ + [ + "Филе курицы готовое", + 0.04, + "кг" + ], + [ + "Шампиньоны", + 0.06, + "кг" + ], + [ + "Лук репчатый", + 0.015, + "кг" + ], + [ + "Сливки", + 0.025, + "кг" + ], + [ + "Сыр гауда", + 0.015, + "кг" + ], + [ + "Масло оливковое", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б08", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 130, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-chicken", + "name": "Филе курицы готовое", + "unit": "кг", + "gross": 0.04, + "net": 0.04, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-mushroom", + "name": "Шампиньоны", + "unit": "кг", + "gross": 0.06, + "net": 0.04, + "unitCost": 340 + }, + { + "productId": "demo-v1-stock-onion", + "name": "Лук репчатый", + "unit": "кг", + "gross": 0.015, + "net": 0.01, + "unitCost": 80 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.025, + "net": 0.02, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.015, + "net": 0.015, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 1100 + } + ], + "steps": [ + "Обжарить нарезанные грибы и лук на масле.", + "Добавить готовую курицу и сливки, распределить по формам.", + "Посыпать сыром и запечь до расплавления." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 87.1, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-stuffed-mushrooms", + "name": "Шампиньоны, запечённые с сыром", + "category": 6, + "catalogSection": "Горячие закуски", + "price": 270, + "weight": "120 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое" + ], + "ingredients": [ + [ + "Шампиньоны", + 0.125, + "кг" + ], + [ + "Сыр сливочный", + 0.02, + "кг" + ], + [ + "Сыр гауда", + 0.01, + "кг" + ], + [ + "Укроп", + 0.003, + "кг" + ], + [ + "Масло оливковое", + 0.003, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б09", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 120, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-mushroom", + "name": "Шампиньоны", + "unit": "кг", + "gross": 0.125, + "net": 0.085, + "unitCost": 340 + }, + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-gouda", + "name": "Сыр гауда", + "unit": "кг", + "gross": 0.01, + "net": 0.01, + "unitCost": 900 + }, + { + "productId": "demo-v1-stock-dill", + "name": "Укроп", + "unit": "кг", + "gross": 0.003, + "net": 0.002, + "unitCost": 700 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.003, + "net": 0.003, + "unitCost": 1100 + } + ], + "steps": [ + "Подготовить шляпки грибов, ножки мелко нарезать.", + "Смешать ножки со сливочным сыром и укропом, наполнить шляпки.", + "Добавить гауду и запечь." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 73.9, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-chicken", + "name": "Куриное филе с овощами и сливочным соусом", + "category": 6, + "catalogSection": "Горячие блюда", + "price": 490, + "weight": "220 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое" + ], + "ingredients": [ + [ + "Филе куриное сырое", + 0.18, + "кг" + ], + [ + "Перец сладкий", + 0.045, + "кг" + ], + [ + "Морковь", + 0.032, + "кг" + ], + [ + "Сливки", + 0.03, + "кг" + ], + [ + "Масло оливковое", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б10", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 220, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-banquet-v1-stock-chicken-raw", + "name": "Филе куриное сырое", + "unit": "кг", + "gross": 0.18, + "net": 0.135, + "unitCost": 480 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.045, + "net": 0.03, + "unitCost": 380 + }, + { + "productId": "demo-v1-stock-carrot", + "name": "Морковь", + "unit": "кг", + "gross": 0.032, + "net": 0.025, + "unitCost": 90 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.03, + "net": 0.025, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 1100 + } + ], + "steps": [ + "Обжарить куриное филе и довести до готовности.", + "Отдельно приготовить перец и морковь.", + "Добавить сливочный соус, подать с овощами; учесть потери при приготовлении." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 126.88, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-cod", + "name": "Треска с лимоном и овощами", + "category": 6, + "catalogSection": "Горячие блюда", + "price": 590, + "weight": "200 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое" + ], + "ingredients": [ + [ + "Филе трески", + 0.17, + "кг" + ], + [ + "Томаты", + 0.045, + "кг" + ], + [ + "Перец сладкий", + 0.03, + "кг" + ], + [ + "Лимон", + 0.015, + "кг" + ], + [ + "Масло оливковое", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б11", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 200, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-banquet-v1-stock-whitefish", + "name": "Филе трески", + "unit": "кг", + "gross": 0.17, + "net": 0.13, + "unitCost": 950 + }, + { + "productId": "demo-v1-stock-tomato", + "name": "Томаты", + "unit": "кг", + "gross": 0.045, + "net": 0.035, + "unitCost": 260 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.03, + "net": 0.02, + "unitCost": 380 + }, + { + "productId": "demo-banquet-v1-stock-lemon", + "name": "Лимон", + "unit": "кг", + "gross": 0.015, + "net": 0.01, + "unitCost": 300 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 1100 + } + ], + "steps": [ + "Подготовить филе и нарезать овощи.", + "Запечь рыбу с овощами, лимоном и маслом до готовности." + ], + "allergens": [ + "Рыба" + ], + "ingredientCost": 194.6, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-beef-hot", + "name": "Ростбиф с грибами в сливочном соусе", + "category": 6, + "catalogSection": "Горячие блюда", + "price": 690, + "weight": "220 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое" + ], + "ingredients": [ + [ + "Ростбиф готовый", + 0.125, + "кг" + ], + [ + "Шампиньоны", + 0.08, + "кг" + ], + [ + "Лук репчатый", + 0.024, + "кг" + ], + [ + "Сливки", + 0.03, + "кг" + ], + [ + "Масло оливковое", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б12", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 220, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-beef", + "name": "Ростбиф готовый", + "unit": "кг", + "gross": 0.125, + "net": 0.12, + "unitCost": 2000 + }, + { + "productId": "demo-v1-stock-mushroom", + "name": "Шампиньоны", + "unit": "кг", + "gross": 0.08, + "net": 0.055, + "unitCost": 340 + }, + { + "productId": "demo-v1-stock-onion", + "name": "Лук репчатый", + "unit": "кг", + "gross": 0.024, + "net": 0.015, + "unitCost": 80 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.03, + "net": 0.025, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 1100 + } + ], + "steps": [ + "Обжарить грибы и лук, добавить сливки.", + "Прогреть готовый ростбиф и подать с грибным соусом." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 299.62, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-mash", + "name": "Картофельное пюре", + "category": 6, + "catalogSection": "Гарниры", + "price": 160, + "weight": "150 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Картофель, Молоко, Масло сливочное" + ], + "ingredients": [ + [ + "Картофель", + 0.17, + "кг" + ], + [ + "Молоко", + 0.025, + "кг" + ], + [ + "Масло сливочное", + 0.01, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б13", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 150, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-banquet-v1-stock-potato", + "name": "Картофель", + "unit": "кг", + "gross": 0.17, + "net": 0.12, + "unitCost": 80 + }, + { + "productId": "demo-banquet-v1-stock-milk", + "name": "Молоко", + "unit": "кг", + "gross": 0.025, + "net": 0.02, + "unitCost": 110 + }, + { + "productId": "demo-banquet-v1-stock-butter", + "name": "Масло сливочное", + "unit": "кг", + "gross": 0.01, + "net": 0.01, + "unitCost": 1100 + } + ], + "steps": [ + "Очистить и отварить картофель, слить воду.", + "Размять с тёплым молоком и сливочным маслом." + ], + "allergens": [ + "Молоко" + ], + "ingredientCost": 27.35, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-rice", + "name": "Рис с овощами", + "category": 6, + "catalogSection": "Гарниры", + "price": 170, + "weight": "150 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое" + ], + "ingredients": [ + [ + "Рис сухой", + 0.04, + "кг" + ], + [ + "Морковь", + 0.025, + "кг" + ], + [ + "Перец сладкий", + 0.025, + "кг" + ], + [ + "Горошек зелёный консервированный", + 0.015, + "кг" + ], + [ + "Масло оливковое", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б14", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 150, + "waterGrams": 50, + "pieces": 1, + "rows": [ + { + "productId": "demo-banquet-v1-stock-rice", + "name": "Рис сухой", + "unit": "кг", + "gross": 0.04, + "net": 0.04, + "unitCost": 180 + }, + { + "productId": "demo-v1-stock-carrot", + "name": "Морковь", + "unit": "кг", + "gross": 0.025, + "net": 0.02, + "unitCost": 90 + }, + { + "productId": "demo-v1-stock-pepper", + "name": "Перец сладкий", + "unit": "кг", + "gross": 0.025, + "net": 0.02, + "unitCost": 380 + }, + { + "productId": "demo-banquet-v1-stock-peas", + "name": "Горошек зелёный консервированный", + "unit": "кг", + "gross": 0.015, + "net": 0.015, + "unitCost": 300 + }, + { + "productId": "demo-v1-stock-oil", + "name": "Масло оливковое", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 1100 + } + ], + "steps": [ + "Отварить рис; учебный расчёт учитывает 50 г поглощённой воды на порцию.", + "Приготовить овощи, добавить горошек и смешать с рисом." + ], + "allergens": [], + "ingredientCost": 28.95, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-berry-cream", + "name": "Ягодный десерт со сливочным кремом", + "category": 6, + "catalogSection": "Десерты", + "price": 240, + "weight": "110 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная" + ], + "ingredients": [ + [ + "Сыр сливочный", + 0.035, + "кг" + ], + [ + "Сливки", + 0.02, + "кг" + ], + [ + "Крошка бисквитная", + 0.015, + "кг" + ], + [ + "Клубника", + 0.028, + "кг" + ], + [ + "Голубика", + 0.011, + "кг" + ], + [ + "Пудра сахарная", + 0.005, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б15", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 110, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-cream", + "name": "Сыр сливочный", + "unit": "кг", + "gross": 0.035, + "net": 0.035, + "unitCost": 850 + }, + { + "productId": "demo-v1-stock-cooking-cream", + "name": "Сливки", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-biscuit", + "name": "Крошка бисквитная", + "unit": "кг", + "gross": 0.015, + "net": 0.015, + "unitCost": 500 + }, + { + "productId": "demo-v1-stock-strawberry", + "name": "Клубника", + "unit": "кг", + "gross": 0.028, + "net": 0.025, + "unitCost": 750 + }, + { + "productId": "demo-v1-stock-blueberry", + "name": "Голубика", + "unit": "кг", + "gross": 0.011, + "net": 0.01, + "unitCost": 1300 + }, + { + "productId": "demo-v1-stock-sugar", + "name": "Пудра сахарная", + "unit": "кг", + "gross": 0.005, + "net": 0.005, + "unitCost": 160 + } + ], + "steps": [ + "Смешать сыр, сливки и сахарную пудру.", + "Выложить в креманку бисквит, крем и подготовленные ягоды." + ], + "allergens": [ + "Молоко", + "Глютен", + "Яйцо в составе бисквита" + ], + "ingredientCost": 83.35, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-cheese-honey", + "name": "Бри с виноградом, мёдом и орехами", + "category": 6, + "catalogSection": "Десерты", + "price": 310, + "weight": "100 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Сыр бри, Виноград, Орех грецкий очищенный, Мёд" + ], + "ingredients": [ + [ + "Сыр бри", + 0.045, + "кг" + ], + [ + "Виноград", + 0.039, + "кг" + ], + [ + "Орех грецкий очищенный", + 0.01, + "кг" + ], + [ + "Мёд", + 0.01, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б16", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 100, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-brie", + "name": "Сыр бри", + "unit": "кг", + "gross": 0.045, + "net": 0.045, + "unitCost": 1600 + }, + { + "productId": "demo-v1-stock-grape", + "name": "Виноград", + "unit": "кг", + "gross": 0.039, + "net": 0.035, + "unitCost": 420 + }, + { + "productId": "demo-v1-stock-walnut", + "name": "Орех грецкий очищенный", + "unit": "кг", + "gross": 0.01, + "net": 0.01, + "unitCost": 1000 + }, + { + "productId": "demo-v1-stock-honey", + "name": "Мёд", + "unit": "кг", + "gross": 0.01, + "net": 0.01, + "unitCost": 800 + } + ], + "steps": [ + "Нарезать бри и подготовить виноград.", + "Подать с грецким орехом и мёдом." + ], + "allergens": [ + "Молоко", + "Орехи" + ], + "ingredientCost": 106.38, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-fruit", + "name": "Фруктово-ягодное ассорти", + "category": 6, + "catalogSection": "Фрукты и ягоды", + "price": 290, + "weight": "180 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Виноград, Клубника, Голубика" + ], + "ingredients": [ + [ + "Виноград", + 0.088, + "кг" + ], + [ + "Клубника", + 0.077, + "кг" + ], + [ + "Голубика", + 0.032, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б17", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 180, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-grape", + "name": "Виноград", + "unit": "кг", + "gross": 0.088, + "net": 0.08, + "unitCost": 420 + }, + { + "productId": "demo-v1-stock-strawberry", + "name": "Клубника", + "unit": "кг", + "gross": 0.077, + "net": 0.07, + "unitCost": 750 + }, + { + "productId": "demo-v1-stock-blueberry", + "name": "Голубика", + "unit": "кг", + "gross": 0.032, + "net": 0.03, + "unitCost": 1300 + } + ], + "steps": [ + "Подготовить ягоды и виноград, удалить плодоножки.", + "Выложить порционное ассорти." + ], + "allergens": [], + "ingredientCost": 136.31, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + }, + { + "id": "demo-banquet-v1-bread", + "name": "Хлебная корзинка с зелёным маслом", + "category": 6, + "catalogSection": "Хлеб и масло", + "price": 120, + "weight": "60 г", + "pieces": 1, + "photo": "", + "demo": true, + "composition": [ + "Багет, Хлеб ржаной, Масло сливочное, Укроп" + ], + "ingredients": [ + [ + "Багет", + 0.03, + "кг" + ], + [ + "Хлеб ржаной", + 0.02, + "кг" + ], + [ + "Масло сливочное", + 0.008, + "кг" + ], + [ + "Укроп", + 0.003, + "кг" + ] + ], + "ttk": { + "number": "ДЕМО-Б18", + "basis": "На 1 порцию / 1 гостя", + "outputGrams": 60, + "waterGrams": 0, + "pieces": 1, + "rows": [ + { + "productId": "demo-v1-stock-baguette", + "name": "Багет", + "unit": "кг", + "gross": 0.03, + "net": 0.03, + "unitCost": 240 + }, + { + "productId": "demo-v1-stock-rye", + "name": "Хлеб ржаной", + "unit": "кг", + "gross": 0.02, + "net": 0.02, + "unitCost": 220 + }, + { + "productId": "demo-banquet-v1-stock-butter", + "name": "Масло сливочное", + "unit": "кг", + "gross": 0.008, + "net": 0.008, + "unitCost": 1100 + }, + { + "productId": "demo-v1-stock-dill", + "name": "Укроп", + "unit": "кг", + "gross": 0.003, + "net": 0.002, + "unitCost": 700 + } + ], + "steps": [ + "Нарезать хлеб, при желании подсушить.", + "Смешать масло с укропом, подать отдельно." + ], + "allergens": [ + "Глютен", + "Молоко" + ], + "ingredientCost": 22.5, + "note": "Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены." + } + } + ], + "stock": [ + { + "id": "demo-banquet-v1-stock-potato", + "name": "Картофель", + "cost": 80, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-butter", + "name": "Масло сливочное", + "cost": 1100, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-milk", + "name": "Молоко", + "cost": 110, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-egg", + "name": "Яйцо куриное без скорлупы", + "cost": 260, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-peas", + "name": "Горошек зелёный консервированный", + "cost": 300, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-chicken-raw", + "name": "Филе куриное сырое", + "cost": 480, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-whitefish", + "name": "Филе трески", + "cost": 950, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-protein", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-lemon", + "name": "Лимон", + "cost": 300, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-fresh", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + }, + { + "id": "demo-banquet-v1-stock-rice", + "name": "Рис сухой", + "cost": 180, + "unit": "кг", + "qty": 0, + "min": 0.1, + "supplierId": "demo-v1-supplier-grocery", + "lastPurchaseDate": "", + "lastPurchasePrice": 0 + } + ] +} diff --git a/public/index.html b/public/index.html index ce3df8a..1c76725 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 f78ec59..80bf082 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,5 +1,5 @@ -const CACHE='sun-catering-pwa-v96-20260918-single-login'; -const VERSION='20260918-single-login'; +const CACHE='sun-catering-pwa-v97-20260918-trial-banquet'; +const VERSION='20260918-trial-banquet'; 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/20260918060000_trial_banquet.sql b/supabase/migrations/20260918060000_trial_banquet.sql new file mode 100644 index 0000000..a7e4ef6 --- /dev/null +++ b/supabase/migrations/20260918060000_trial_banquet.sql @@ -0,0 +1,102 @@ +-- Generated by ops/demo/build-trial-banquet.mjs. Additive demo upgrade only. +begin; +create or replace function public.caterium_trial_banquet_seed_v1() +returns jsonb language sql immutable security definer set search_path=public,pg_temp +as $fn$ select $demo${"version":1,"banquet":[{"id":"demo-banquet-v1-caprese","name":"Капрезе с моцареллой и базиликом","category":6,"catalogSection":"Холодные закуски","price":290,"weight":"120 г","pieces":1,"photo":"","demo":true,"composition":["Моцарелла мини, Томаты, Базилик, Масло оливковое"],"ingredients":[["Моцарелла мини",0.05,"кг"],["Томаты",0.07,"кг"],["Базилик",0.006,"кг"],["Масло оливковое",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б01","basis":"На 1 порцию / 1 гостя","outputGrams":120,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-mozzarella","name":"Моцарелла мини","unit":"кг","gross":0.05,"net":0.05,"unitCost":950},{"productId":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","gross":0.07,"net":0.06,"unitCost":260},{"productId":"demo-v1-stock-basil","name":"Базилик","unit":"кг","gross":0.006,"net":0.005,"unitCost":1500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.005,"net":0.005,"unitCost":1100}],"steps":["Нарезать томаты и моцареллу.","Выложить слоями, добавить базилик и масло."],"allergens":["Молоко"],"ingredientCost":80.2,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-roastbeef","name":"Ростбиф с корнишонами и горчичным соусом","category":6,"catalogSection":"Холодные закуски","price":390,"weight":"100 г","pieces":1,"photo":"","demo":true,"composition":["Ростбиф готовый, Корнишоны, Горчица, Сливки"],"ingredients":[["Ростбиф готовый",0.065,"кг"],["Корнишоны",0.022,"кг"],["Горчица",0.005,"кг"],["Сливки",0.01,"кг"]],"ttk":{"number":"ДЕМО-Б02","basis":"На 1 порцию / 1 гостя","outputGrams":100,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-beef","name":"Ростбиф готовый","unit":"кг","gross":0.065,"net":0.065,"unitCost":2000},{"productId":"demo-v1-stock-pickle","name":"Корнишоны","unit":"кг","gross":0.022,"net":0.02,"unitCost":400},{"productId":"demo-v1-stock-mustard","name":"Горчица","unit":"кг","gross":0.005,"net":0.005,"unitCost":350},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.01,"net":0.01,"unitCost":500}],"steps":["Нарезать готовый ростбиф тонкими ломтиками.","Смешать сливки с горчицей, подать с корнишонами."],"allergens":["Молоко","Горчица"],"ingredientCost":145.55,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-salmon-roll","name":"Рулетики из лосося со сливочным сыром","category":6,"catalogSection":"Холодные закуски","price":350,"weight":"90 г","pieces":1,"photo":"","demo":true,"composition":["Лосось слабосолёный, Сыр сливочный, Огурцы, Укроп"],"ingredients":[["Лосось слабосолёный",0.045,"кг"],["Сыр сливочный",0.025,"кг"],["Огурцы",0.022,"кг"],["Укроп",0.003,"кг"]],"ttk":{"number":"ДЕМО-Б03","basis":"На 1 порцию / 1 гостя","outputGrams":90,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-salmon","name":"Лосось слабосолёный","unit":"кг","gross":0.045,"net":0.045,"unitCost":2400},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.025,"net":0.025,"unitCost":850},{"productId":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","gross":0.022,"net":0.018,"unitCost":220},{"productId":"demo-v1-stock-dill","name":"Укроп","unit":"кг","gross":0.003,"net":0.002,"unitCost":700}],"steps":["Нарезать лосось и огурец тонкими ломтиками.","Распределить сыр, свернуть рулетики, украсить укропом."],"allergens":["Рыба","Молоко"],"ingredientCost":136.19,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-hummus","name":"Овощные палочки с хумусом","category":6,"catalogSection":"Холодные закуски","price":230,"weight":"160 г","pieces":1,"photo":"","demo":true,"composition":["Морковь, Огурцы, Перец сладкий, Хумус готовый"],"ingredients":[["Морковь",0.048,"кг"],["Огурцы",0.044,"кг"],["Перец сладкий",0.04,"кг"],["Хумус готовый",0.05,"кг"]],"ttk":{"number":"ДЕМО-Б04","basis":"На 1 порцию / 1 гостя","outputGrams":160,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-carrot","name":"Морковь","unit":"кг","gross":0.048,"net":0.04,"unitCost":90},{"productId":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","gross":0.044,"net":0.04,"unitCost":220},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.04,"net":0.03,"unitCost":380},{"productId":"demo-v1-stock-hummus","name":"Хумус готовый","unit":"кг","gross":0.05,"net":0.05,"unitCost":600}],"steps":["Очистить овощи и нарезать палочками.","Подать с порцией готового хумуса."],"allergens":["Кунжут в составе хумуса"],"ingredientCost":59.2,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-caesar","name":"Салат с курицей, сухариками и сыром","category":6,"catalogSection":"Салаты","price":340,"weight":"180 г","pieces":1,"photo":"","demo":true,"composition":["Филе курицы готовое, Салат листовой, Томаты черри, Хлеб тостовый, Сыр гауда, Сливки, Горчица"],"ingredients":[["Филе курицы готовое",0.06,"кг"],["Салат листовой",0.042,"кг"],["Томаты черри",0.035,"кг"],["Хлеб тостовый",0.023,"кг"],["Сыр гауда",0.015,"кг"],["Сливки",0.015,"кг"],["Горчица",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б05","basis":"На 1 порцию / 1 гостя","outputGrams":180,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-chicken","name":"Филе курицы готовое","unit":"кг","gross":0.06,"net":0.06,"unitCost":850},{"productId":"demo-v1-stock-lettuce","name":"Салат листовой","unit":"кг","gross":0.042,"net":0.035,"unitCost":700},{"productId":"demo-v1-stock-cherry","name":"Томаты черри","unit":"кг","gross":0.035,"net":0.03,"unitCost":450},{"productId":"demo-v1-stock-toast","name":"Хлеб тостовый","unit":"кг","gross":0.023,"net":0.02,"unitCost":240},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.015,"net":0.015,"unitCost":900},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.015,"net":0.015,"unitCost":500},{"productId":"demo-v1-stock-mustard","name":"Горчица","unit":"кг","gross":0.005,"net":0.005,"unitCost":350}],"steps":["Подсушить кубики хлеба, подготовить салат и томаты.","Нарезать готовую курицу, соединить с овощами и сыром.","Заправить сливками с горчицей, сверху добавить сухарики."],"allergens":["Глютен","Молоко","Горчица"],"ingredientCost":124.42,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-olivier","name":"Оливье с индейкой","category":6,"catalogSection":"Салаты","price":280,"weight":"180 г","pieces":1,"photo":"","demo":true,"composition":["Картофель, Морковь, Ветчина из индейки, Яйцо куриное без скорлупы, Горошек зелёный консервированный, Корнишоны, Сливки, Горчица"],"ingredients":[["Картофель",0.065,"кг"],["Морковь",0.026,"кг"],["Ветчина из индейки",0.035,"кг"],["Яйцо куриное без скорлупы",0.022,"кг"],["Горошек зелёный консервированный",0.02,"кг"],["Корнишоны",0.022,"кг"],["Сливки",0.012,"кг"],["Горчица",0.003,"кг"]],"ttk":{"number":"ДЕМО-Б06","basis":"На 1 порцию / 1 гостя","outputGrams":180,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-banquet-v1-stock-potato","name":"Картофель","unit":"кг","gross":0.065,"net":0.05,"unitCost":80},{"productId":"demo-v1-stock-carrot","name":"Морковь","unit":"кг","gross":0.026,"net":0.02,"unitCost":90},{"productId":"demo-v1-stock-turkey","name":"Ветчина из индейки","unit":"кг","gross":0.035,"net":0.035,"unitCost":1000},{"productId":"demo-banquet-v1-stock-egg","name":"Яйцо куриное без скорлупы","unit":"кг","gross":0.022,"net":0.02,"unitCost":260},{"productId":"demo-banquet-v1-stock-peas","name":"Горошек зелёный консервированный","unit":"кг","gross":0.02,"net":0.02,"unitCost":300},{"productId":"demo-v1-stock-pickle","name":"Корнишоны","unit":"кг","gross":0.022,"net":0.02,"unitCost":400},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.012,"net":0.012,"unitCost":500},{"productId":"demo-v1-stock-mustard","name":"Горчица","unit":"кг","gross":0.003,"net":0.003,"unitCost":350}],"steps":["Отварить картофель, морковь и яйцо; остудить.","Нарезать овощи, яйцо, ветчину и корнишоны кубиком.","Добавить горошек и заправить сливками с горчицей."],"allergens":["Яйцо","Молоко","Горчица"],"ingredientCost":70.11,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-greek","name":"Овощной салат с моцареллой и маслинами","category":6,"catalogSection":"Салаты","price":290,"weight":"180 г","pieces":1,"photo":"","demo":true,"composition":["Томаты, Огурцы, Перец сладкий, Моцарелла мини, Маслины без косточек, Масло оливковое"],"ingredients":[["Томаты",0.055,"кг"],["Огурцы",0.044,"кг"],["Перец сладкий",0.032,"кг"],["Моцарелла мини",0.04,"кг"],["Маслины без косточек",0.017,"кг"],["Масло оливковое",0.01,"кг"]],"ttk":{"number":"ДЕМО-Б07","basis":"На 1 порцию / 1 гостя","outputGrams":180,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","gross":0.055,"net":0.05,"unitCost":260},{"productId":"demo-v1-stock-cucumber","name":"Огурцы","unit":"кг","gross":0.044,"net":0.04,"unitCost":220},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.032,"net":0.025,"unitCost":380},{"productId":"demo-v1-stock-mozzarella","name":"Моцарелла мини","unit":"кг","gross":0.04,"net":0.04,"unitCost":950},{"productId":"demo-v1-stock-olive","name":"Маслины без косточек","unit":"кг","gross":0.017,"net":0.015,"unitCost":650},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.01,"net":0.01,"unitCost":1100}],"steps":["Подготовить и крупно нарезать овощи.","Добавить моцареллу и маслины, заправить маслом."],"allergens":["Молоко"],"ingredientCost":96.19,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-julienne","name":"Жюльен с курицей и грибами","category":6,"catalogSection":"Горячие закуски","price":310,"weight":"130 г","pieces":1,"photo":"","demo":true,"composition":["Филе курицы готовое, Шампиньоны, Лук репчатый, Сливки, Сыр гауда, Масло оливковое"],"ingredients":[["Филе курицы готовое",0.04,"кг"],["Шампиньоны",0.06,"кг"],["Лук репчатый",0.015,"кг"],["Сливки",0.025,"кг"],["Сыр гауда",0.015,"кг"],["Масло оливковое",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б08","basis":"На 1 порцию / 1 гостя","outputGrams":130,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-chicken","name":"Филе курицы готовое","unit":"кг","gross":0.04,"net":0.04,"unitCost":850},{"productId":"demo-v1-stock-mushroom","name":"Шампиньоны","unit":"кг","gross":0.06,"net":0.04,"unitCost":340},{"productId":"demo-v1-stock-onion","name":"Лук репчатый","unit":"кг","gross":0.015,"net":0.01,"unitCost":80},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.025,"net":0.02,"unitCost":500},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.015,"net":0.015,"unitCost":900},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.005,"net":0.005,"unitCost":1100}],"steps":["Обжарить нарезанные грибы и лук на масле.","Добавить готовую курицу и сливки, распределить по формам.","Посыпать сыром и запечь до расплавления."],"allergens":["Молоко"],"ingredientCost":87.1,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-stuffed-mushrooms","name":"Шампиньоны, запечённые с сыром","category":6,"catalogSection":"Горячие закуски","price":270,"weight":"120 г","pieces":1,"photo":"","demo":true,"composition":["Шампиньоны, Сыр сливочный, Сыр гауда, Укроп, Масло оливковое"],"ingredients":[["Шампиньоны",0.125,"кг"],["Сыр сливочный",0.02,"кг"],["Сыр гауда",0.01,"кг"],["Укроп",0.003,"кг"],["Масло оливковое",0.003,"кг"]],"ttk":{"number":"ДЕМО-Б09","basis":"На 1 порцию / 1 гостя","outputGrams":120,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-mushroom","name":"Шампиньоны","unit":"кг","gross":0.125,"net":0.085,"unitCost":340},{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.02,"net":0.02,"unitCost":850},{"productId":"demo-v1-stock-gouda","name":"Сыр гауда","unit":"кг","gross":0.01,"net":0.01,"unitCost":900},{"productId":"demo-v1-stock-dill","name":"Укроп","unit":"кг","gross":0.003,"net":0.002,"unitCost":700},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.003,"net":0.003,"unitCost":1100}],"steps":["Подготовить шляпки грибов, ножки мелко нарезать.","Смешать ножки со сливочным сыром и укропом, наполнить шляпки.","Добавить гауду и запечь."],"allergens":["Молоко"],"ingredientCost":73.9,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-chicken","name":"Куриное филе с овощами и сливочным соусом","category":6,"catalogSection":"Горячие блюда","price":490,"weight":"220 г","pieces":1,"photo":"","demo":true,"composition":["Филе куриное сырое, Перец сладкий, Морковь, Сливки, Масло оливковое"],"ingredients":[["Филе куриное сырое",0.18,"кг"],["Перец сладкий",0.045,"кг"],["Морковь",0.032,"кг"],["Сливки",0.03,"кг"],["Масло оливковое",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б10","basis":"На 1 порцию / 1 гостя","outputGrams":220,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-banquet-v1-stock-chicken-raw","name":"Филе куриное сырое","unit":"кг","gross":0.18,"net":0.135,"unitCost":480},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.045,"net":0.03,"unitCost":380},{"productId":"demo-v1-stock-carrot","name":"Морковь","unit":"кг","gross":0.032,"net":0.025,"unitCost":90},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.03,"net":0.025,"unitCost":500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.005,"net":0.005,"unitCost":1100}],"steps":["Обжарить куриное филе и довести до готовности.","Отдельно приготовить перец и морковь.","Добавить сливочный соус, подать с овощами; учесть потери при приготовлении."],"allergens":["Молоко"],"ingredientCost":126.88,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-cod","name":"Треска с лимоном и овощами","category":6,"catalogSection":"Горячие блюда","price":590,"weight":"200 г","pieces":1,"photo":"","demo":true,"composition":["Филе трески, Томаты, Перец сладкий, Лимон, Масло оливковое"],"ingredients":[["Филе трески",0.17,"кг"],["Томаты",0.045,"кг"],["Перец сладкий",0.03,"кг"],["Лимон",0.015,"кг"],["Масло оливковое",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б11","basis":"На 1 порцию / 1 гостя","outputGrams":200,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-banquet-v1-stock-whitefish","name":"Филе трески","unit":"кг","gross":0.17,"net":0.13,"unitCost":950},{"productId":"demo-v1-stock-tomato","name":"Томаты","unit":"кг","gross":0.045,"net":0.035,"unitCost":260},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.03,"net":0.02,"unitCost":380},{"productId":"demo-banquet-v1-stock-lemon","name":"Лимон","unit":"кг","gross":0.015,"net":0.01,"unitCost":300},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.005,"net":0.005,"unitCost":1100}],"steps":["Подготовить филе и нарезать овощи.","Запечь рыбу с овощами, лимоном и маслом до готовности."],"allergens":["Рыба"],"ingredientCost":194.6,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-beef-hot","name":"Ростбиф с грибами в сливочном соусе","category":6,"catalogSection":"Горячие блюда","price":690,"weight":"220 г","pieces":1,"photo":"","demo":true,"composition":["Ростбиф готовый, Шампиньоны, Лук репчатый, Сливки, Масло оливковое"],"ingredients":[["Ростбиф готовый",0.125,"кг"],["Шампиньоны",0.08,"кг"],["Лук репчатый",0.024,"кг"],["Сливки",0.03,"кг"],["Масло оливковое",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б12","basis":"На 1 порцию / 1 гостя","outputGrams":220,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-beef","name":"Ростбиф готовый","unit":"кг","gross":0.125,"net":0.12,"unitCost":2000},{"productId":"demo-v1-stock-mushroom","name":"Шампиньоны","unit":"кг","gross":0.08,"net":0.055,"unitCost":340},{"productId":"demo-v1-stock-onion","name":"Лук репчатый","unit":"кг","gross":0.024,"net":0.015,"unitCost":80},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.03,"net":0.025,"unitCost":500},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.005,"net":0.005,"unitCost":1100}],"steps":["Обжарить грибы и лук, добавить сливки.","Прогреть готовый ростбиф и подать с грибным соусом."],"allergens":["Молоко"],"ingredientCost":299.62,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-mash","name":"Картофельное пюре","category":6,"catalogSection":"Гарниры","price":160,"weight":"150 г","pieces":1,"photo":"","demo":true,"composition":["Картофель, Молоко, Масло сливочное"],"ingredients":[["Картофель",0.17,"кг"],["Молоко",0.025,"кг"],["Масло сливочное",0.01,"кг"]],"ttk":{"number":"ДЕМО-Б13","basis":"На 1 порцию / 1 гостя","outputGrams":150,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-banquet-v1-stock-potato","name":"Картофель","unit":"кг","gross":0.17,"net":0.12,"unitCost":80},{"productId":"demo-banquet-v1-stock-milk","name":"Молоко","unit":"кг","gross":0.025,"net":0.02,"unitCost":110},{"productId":"demo-banquet-v1-stock-butter","name":"Масло сливочное","unit":"кг","gross":0.01,"net":0.01,"unitCost":1100}],"steps":["Очистить и отварить картофель, слить воду.","Размять с тёплым молоком и сливочным маслом."],"allergens":["Молоко"],"ingredientCost":27.35,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-rice","name":"Рис с овощами","category":6,"catalogSection":"Гарниры","price":170,"weight":"150 г","pieces":1,"photo":"","demo":true,"composition":["Рис сухой, Морковь, Перец сладкий, Горошек зелёный консервированный, Масло оливковое"],"ingredients":[["Рис сухой",0.04,"кг"],["Морковь",0.025,"кг"],["Перец сладкий",0.025,"кг"],["Горошек зелёный консервированный",0.015,"кг"],["Масло оливковое",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б14","basis":"На 1 порцию / 1 гостя","outputGrams":150,"waterGrams":50,"pieces":1,"rows":[{"productId":"demo-banquet-v1-stock-rice","name":"Рис сухой","unit":"кг","gross":0.04,"net":0.04,"unitCost":180},{"productId":"demo-v1-stock-carrot","name":"Морковь","unit":"кг","gross":0.025,"net":0.02,"unitCost":90},{"productId":"demo-v1-stock-pepper","name":"Перец сладкий","unit":"кг","gross":0.025,"net":0.02,"unitCost":380},{"productId":"demo-banquet-v1-stock-peas","name":"Горошек зелёный консервированный","unit":"кг","gross":0.015,"net":0.015,"unitCost":300},{"productId":"demo-v1-stock-oil","name":"Масло оливковое","unit":"кг","gross":0.005,"net":0.005,"unitCost":1100}],"steps":["Отварить рис; учебный расчёт учитывает 50 г поглощённой воды на порцию.","Приготовить овощи, добавить горошек и смешать с рисом."],"allergens":[],"ingredientCost":28.95,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-berry-cream","name":"Ягодный десерт со сливочным кремом","category":6,"catalogSection":"Десерты","price":240,"weight":"110 г","pieces":1,"photo":"","demo":true,"composition":["Сыр сливочный, Сливки, Крошка бисквитная, Клубника, Голубика, Пудра сахарная"],"ingredients":[["Сыр сливочный",0.035,"кг"],["Сливки",0.02,"кг"],["Крошка бисквитная",0.015,"кг"],["Клубника",0.028,"кг"],["Голубика",0.011,"кг"],["Пудра сахарная",0.005,"кг"]],"ttk":{"number":"ДЕМО-Б15","basis":"На 1 порцию / 1 гостя","outputGrams":110,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-cream","name":"Сыр сливочный","unit":"кг","gross":0.035,"net":0.035,"unitCost":850},{"productId":"demo-v1-stock-cooking-cream","name":"Сливки","unit":"кг","gross":0.02,"net":0.02,"unitCost":500},{"productId":"demo-v1-stock-biscuit","name":"Крошка бисквитная","unit":"кг","gross":0.015,"net":0.015,"unitCost":500},{"productId":"demo-v1-stock-strawberry","name":"Клубника","unit":"кг","gross":0.028,"net":0.025,"unitCost":750},{"productId":"demo-v1-stock-blueberry","name":"Голубика","unit":"кг","gross":0.011,"net":0.01,"unitCost":1300},{"productId":"demo-v1-stock-sugar","name":"Пудра сахарная","unit":"кг","gross":0.005,"net":0.005,"unitCost":160}],"steps":["Смешать сыр, сливки и сахарную пудру.","Выложить в креманку бисквит, крем и подготовленные ягоды."],"allergens":["Молоко","Глютен","Яйцо в составе бисквита"],"ingredientCost":83.35,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-cheese-honey","name":"Бри с виноградом, мёдом и орехами","category":6,"catalogSection":"Десерты","price":310,"weight":"100 г","pieces":1,"photo":"","demo":true,"composition":["Сыр бри, Виноград, Орех грецкий очищенный, Мёд"],"ingredients":[["Сыр бри",0.045,"кг"],["Виноград",0.039,"кг"],["Орех грецкий очищенный",0.01,"кг"],["Мёд",0.01,"кг"]],"ttk":{"number":"ДЕМО-Б16","basis":"На 1 порцию / 1 гостя","outputGrams":100,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-brie","name":"Сыр бри","unit":"кг","gross":0.045,"net":0.045,"unitCost":1600},{"productId":"demo-v1-stock-grape","name":"Виноград","unit":"кг","gross":0.039,"net":0.035,"unitCost":420},{"productId":"demo-v1-stock-walnut","name":"Орех грецкий очищенный","unit":"кг","gross":0.01,"net":0.01,"unitCost":1000},{"productId":"demo-v1-stock-honey","name":"Мёд","unit":"кг","gross":0.01,"net":0.01,"unitCost":800}],"steps":["Нарезать бри и подготовить виноград.","Подать с грецким орехом и мёдом."],"allergens":["Молоко","Орехи"],"ingredientCost":106.38,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-fruit","name":"Фруктово-ягодное ассорти","category":6,"catalogSection":"Фрукты и ягоды","price":290,"weight":"180 г","pieces":1,"photo":"","demo":true,"composition":["Виноград, Клубника, Голубика"],"ingredients":[["Виноград",0.088,"кг"],["Клубника",0.077,"кг"],["Голубика",0.032,"кг"]],"ttk":{"number":"ДЕМО-Б17","basis":"На 1 порцию / 1 гостя","outputGrams":180,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-grape","name":"Виноград","unit":"кг","gross":0.088,"net":0.08,"unitCost":420},{"productId":"demo-v1-stock-strawberry","name":"Клубника","unit":"кг","gross":0.077,"net":0.07,"unitCost":750},{"productId":"demo-v1-stock-blueberry","name":"Голубика","unit":"кг","gross":0.032,"net":0.03,"unitCost":1300}],"steps":["Подготовить ягоды и виноград, удалить плодоножки.","Выложить порционное ассорти."],"allergens":[],"ingredientCost":136.31,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}},{"id":"demo-banquet-v1-bread","name":"Хлебная корзинка с зелёным маслом","category":6,"catalogSection":"Хлеб и масло","price":120,"weight":"60 г","pieces":1,"photo":"","demo":true,"composition":["Багет, Хлеб ржаной, Масло сливочное, Укроп"],"ingredients":[["Багет",0.03,"кг"],["Хлеб ржаной",0.02,"кг"],["Масло сливочное",0.008,"кг"],["Укроп",0.003,"кг"]],"ttk":{"number":"ДЕМО-Б18","basis":"На 1 порцию / 1 гостя","outputGrams":60,"waterGrams":0,"pieces":1,"rows":[{"productId":"demo-v1-stock-baguette","name":"Багет","unit":"кг","gross":0.03,"net":0.03,"unitCost":240},{"productId":"demo-v1-stock-rye","name":"Хлеб ржаной","unit":"кг","gross":0.02,"net":0.02,"unitCost":220},{"productId":"demo-banquet-v1-stock-butter","name":"Масло сливочное","unit":"кг","gross":0.008,"net":0.008,"unitCost":1100},{"productId":"demo-v1-stock-dill","name":"Укроп","unit":"кг","gross":0.003,"net":0.002,"unitCost":700}],"steps":["Нарезать хлеб, при желании подсушить.","Смешать масло с укропом, подать отдельно."],"allergens":["Глютен","Молоко"],"ingredientCost":22.5,"note":"Учебная рецептура и примерные цены. Выход указан после приготовления. Перед использованием в работе уточните нормы, стоимость и аллергены."}}],"stock":[{"id":"demo-banquet-v1-stock-potato","name":"Картофель","cost":80,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-butter","name":"Масло сливочное","cost":1100,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-milk","name":"Молоко","cost":110,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-egg","name":"Яйцо куриное без скорлупы","cost":260,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-peas","name":"Горошек зелёный консервированный","cost":300,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-chicken-raw","name":"Филе куриное сырое","cost":480,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-whitefish","name":"Филе трески","cost":950,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-protein","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-lemon","name":"Лимон","cost":300,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-fresh","lastPurchaseDate":"","lastPurchasePrice":0},{"id":"demo-banquet-v1-stock-rice","name":"Рис сухой","cost":180,"unit":"кг","qty":0,"min":0.1,"supplierId":"demo-v1-supplier-grocery","lastPurchaseDate":"","lastPurchasePrice":0}]}$demo$::jsonb $fn$; +revoke all on function public.caterium_trial_banquet_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'); +end $fn$; + + +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 not (v_status->>'canUpgrade')::boolean 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$; +revoke all on function public.caterium_install_trial_banquet(uuid) from public,anon; +grant execute on function public.caterium_install_trial_banquet(uuid) to authenticated; + +-- Both onboarding's existing trigger and the install button use this entry point. +create or replace function public.caterium_install_trial_demo(p_workspace uuid) +returns jsonb language plpgsql security definer set search_path=public,pg_temp +as $fn$ +declare + v_state public.sun_app_state%rowtype; v_status jsonb; v_seed jsonb; v_storage jsonb; v_moves jsonb; v_marker jsonb; +begin + if auth.uid() is null or public.sun_member_role(p_workspace) is distinct from 'admin' then raise exception 'Administrator membership required'; end if; + perform 1 from public.sun_workspaces where id=p_workspace for update; + v_status:=public.caterium_trial_demo_status(p_workspace); + if not (v_status->>'eligible')::boolean then raise exception 'Демонстрационная база доступна в пробной или собственной тестовой компании разработчика'; end if; + if (v_status->>'installed')::boolean then return public.caterium_install_trial_banquet(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)); + return public.caterium_install_trial_banquet(p_workspace); +end $fn$; + +notify pgrst,'reload schema'; +commit; diff --git a/tests/recovery/smoke.mjs b/tests/recovery/smoke.mjs index 6b3e7d1..32c7487 100644 --- a/tests/recovery/smoke.mjs +++ b/tests/recovery/smoke.mjs @@ -61,20 +61,41 @@ 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,10);assert.equal(training.sunStock.v.length,43);assert.equal(training.sunSuppliers.v.length,5); +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.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,10); +assert.equal((await one('select public.sun_v17_entity_snapshot($1) as s',[foreign])).s.catalog.length,28); assert.ok(training.sunStockMoves.v.length>0);assert.ok(training.sunStock.v.some(p=>p.qty===0)); -for(const b of training.sunBoxes.v){assert.ok(b.ttk.ingredientCost>0);assert.equal(b.ttk.outputGrams,Math.round(b.ttk.rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0)));for(const r of b.ttk.rows){assert.ok(r.gross>=r.net);assert.ok(training.sunStock.v.some(p=>p.id===r.productId&&p.name===r.name&&p.unit===r.unit&&p.cost===r.unitCost));}} +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));}} await q('select public.caterium_install_trial_demo($1)',[foreign]); assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,demoRow.revision,'idempotent demo install does not overwrite'); -const filled=structuredClone(demoRow.payload);delete filled.storage.sunTrialDemoV1; -await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,filled,'test-existing-catalog',demoRow.revision]); +// 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[0].price=4321; +legacy.storage.sunStock.v=legacy.storage.sunStock.v.filter(p=>!p.id.startsWith('demo-banquet-')); +legacy.storage.sunStock.v[0].qty=7.123; +delete legacy.storage.sunTrialDemoV1.v.banquetVersion; +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); +await q('select public.caterium_install_trial_demo($1)',[foreign]); +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); +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); +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_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 * from public.sun_v17_clients_snapshot_v1773($1)',[ws],/Access denied/); await denied('select public.sun_dev_dashboard()',[],/administrator/); await actor(ids[3]); @@ -90,6 +111,7 @@ await q("select public.sun_dev_create_company('Recovery test company','test3@exa 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 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]]); @@ -100,9 +122,10 @@ await actor(ids[0]); await q('select public.caterium_install_trial_demo($1)',[devWs]); const seeded=(await one('select * from public.sun_fetch_app_state($1)',[devWs])).payload.storage; assert.deepEqual(seeded.sunClientLoyaltyV1,preserved.storage.sunClientLoyaltyV1);assert.deepEqual(seeded.sunCompanyProfileV1,preserved.storage.sunCompanyProfileV1); -assert.equal(seeded.sunBoxes.v.length,10,'developer own company works without developer-panel MFA'); +assert.equal(seeded.sunBoxes.v.length,28,'developer own company works without developer-panel MFA'); +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 TTKs, stock links, idempotency, populated catalog guard, Solnce exclusion, own developer account and preserved clients'); +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'); console.log('PASS smoke: owner onboarding, save/read, optimistic conflict, normalized catalog/orders/clients, backups, employees, cross-tenant isolation, viewer write denial, AAL2, developer RPCs'); await db.close(); } diff --git a/tests/release-check.mjs b/tests/release-check.mjs index 2acfc23..52d0be4 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('v96-20260918-single-login')&&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-single-login')&&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('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(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('v96-20260918-single-login'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v97-20260918-trial-banquet'),'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-single-login'),'index cache bust is v17.7.3'); -check(sw.includes('v96-20260918-single-login')&&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-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(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 75a1fb7..5b81916 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-single-login')||!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-single-login')||!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-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(!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 3b5aa33..fbce412 100644 --- a/tests/trial-demo.spec.mjs +++ b/tests/trial-demo.spec.mjs @@ -1,6 +1,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]}; 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()); @@ -22,7 +24,7 @@ async function ready(page){ // Hydrate AFTER stock UI boot: the real cloud path must update its closure. for(const [key,value] of Object.entries({sunBoxes:d.boxes,sunStock:d.stock,sunSuppliers:d.suppliers,sunStockMoves:[],sunOrders:[],sunTrialDemoV1:{version:1,batch:d.batch,scenario:d.scenario}}))localStorage.setItem(key,JSON.stringify(value)); boxes=structuredClone(d.boxes);orders=[];window.dispatchEvent(new Event('sun:cloud-state-applied'));window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render();window.CateriumTrialDemo.render(); - },demo); + },completeDemo); await expect(page.locator('#ctTrialDemo')).toBeVisible(); } test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{ @@ -59,6 +61,32 @@ test('demo photo assets load and company changes remove the trial guide',async({ await expect(page.locator('#ctTrialDemo')).toHaveCount(0); }); +test('trial banquet categories, per-guest TTK, shopping and stock work together',async({page})=>{ + await ready(page); + await page.getByRole('button',{name:'Банкетное меню',exact:true}).click(); + await expect(page.locator('[data-banquet-row]')).toHaveCount(18); + await page.locator('[data-banquet-group="sides"]').click();await expect(page.locator('[data-banquet-row]')).toHaveCount(2); + await page.locator('[data-banquet-ttk="demo-banquet-v1-mash"]').click(); + await expect(page.locator('#ctDemoTtk')).toContainText('На 1 порцию / 1 гостя'); + await expect(page.locator('#ctDemoTtk')).toContainText('150 г');await expect(page.locator('#ctDemoTtk')).toContainText('Цена порции: 160 ₽'); + await page.getByRole('button',{name:'Закрыть ТТК',exact:true}).click(); + await page.locator('[data-banquet-item="demo-banquet-v1-mash"]').check(); + await page.locator('[data-banquet-group="desserts"]').click();await expect(page.locator('[data-banquet-row]')).toHaveCount(2); + await page.locator('[data-banquet-group="main"]').click();await page.locator('[data-banquet-item="demo-banquet-v1-chicken"]').check(); + await page.locator('[data-banquet-guests]').fill('4');await expect(page.locator('[data-banquet-total]')).toHaveText('2 600 ₽'); + await page.locator('[data-banquet-apply]').click(); + 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()}); + expect(await page.evaluate(()=>orders.at(-1).lines.map(l=>({id:l.id,qty:l.qty})))).toEqual([{id:'demo-banquet-v1-chicken',qty:4},{id:'demo-banquet-v1-mash',qty:4}]); + await page.getByRole('button',{name:'Закупки',exact:true}).first().click(); + await expect(page.locator('[data-shop-key="картофель|кг"]')).toContainText('0,68'); + await expect(page.locator('[data-shop-key="филе куриное сырое|кг"]')).toContainText('0,72'); + await page.getByRole('button',{name:'Склад',exact:true}).first().click(); + await page.evaluate(()=>window.stockMovement('demo-banquet-v1-stock-potato','in')); + await page.locator('#stockMoveQty').fill('1');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-banquet-v1-stock-potato').qty)).toBeCloseTo(.32,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'});