Add 18 trial banquet dishes with portion TTKs and additive stock seed
This commit is contained in:
parent
4fe984b584
commit
18ff2b9c30
@ -11,7 +11,7 @@
|
|||||||
"serverReady": true,
|
"serverReady": true,
|
||||||
"workspaceAutoDiscovery": true,
|
"workspaceAutoDiscovery": true,
|
||||||
"invitesTemporarilyDisabled": false,
|
"invitesTemporarilyDisabled": false,
|
||||||
"pwaCache": "v96-20260918-single-login",
|
"pwaCache": "v97-20260918-trial-banquet",
|
||||||
"fullOfferDescriptions": true,
|
"fullOfferDescriptions": true,
|
||||||
"dynamicOfferRows": true,
|
"dynamicOfferRows": true,
|
||||||
"pdfOfferDescriptionFix": true,
|
"pdfOfferDescriptionFix": true,
|
||||||
|
|||||||
18
ops/demo/build-trial-banquet.mjs
Normal file
18
ops/demo/build-trial-banquet.mjs
Normal file
@ -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.`);
|
||||||
77
ops/demo/trial-banquet-data.mjs
Normal file
77
ops/demo/trial-banquet-data.mjs
Normal file
@ -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};
|
||||||
47
ops/demo/trial-banquet.sql
Normal file
47
ops/demo/trial-banquet.sql
Normal file
@ -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;
|
||||||
@ -13,7 +13,7 @@
|
|||||||
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
|
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
|
||||||
"test": "npm run check:deploy",
|
"test": "npm run check:deploy",
|
||||||
"deploy:cloudflare-backup": "wrangler 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": {
|
"devDependencies": {
|
||||||
"@electric-sql/pglite": "0.5.8",
|
"@electric-sql/pglite": "0.5.8",
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
(()=>{
|
(()=>{
|
||||||
'use strict';
|
'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';
|
let packageId='all',groupId='all';
|
||||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const money=v=>Math.round(Number(v)||0).toLocaleString('ru-RU')+' ₽';
|
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 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 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))}
|
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));
|
const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1));
|
||||||
@ -25,10 +25,11 @@
|
|||||||
return `<div class="ct-banquet" aria-label="Конструктор банкетного меню">
|
return `<div class="ct-banquet" aria-label="Конструктор банкетного меню">
|
||||||
<div class="ct-banquet-intro"><div><span class="ct-eyebrow">Банкетное меню</span><h2>Соберите меню для гостей</h2><p>Выберите готовое меню или отметьте отдельные блюда.</p></div><button class="outline" type="button" data-banquet-add>+ Добавить блюдо</button></div>
|
<div class="ct-banquet-intro"><div><span class="ct-eyebrow">Банкетное меню</span><h2>Соберите меню для гостей</h2><p>Выберите готовое меню или отметьте отдельные блюда.</p></div><button class="outline" type="button" data-banquet-add>+ Добавить блюдо</button></div>
|
||||||
${menus.length?`<div class="ct-banquet-packages" aria-label="Пакеты меню"><button type="button" data-banquet-package="all" aria-pressed="${packageId==='all'}"><b>Все блюда</b><small>${catalog.length} позиций</small></button>${menus.map(m=>`<button type="button" data-banquet-package="${esc(m.id)}" aria-pressed="${packageId===m.id}"><b>${esc(m.name)}</b><span>${m.price?money(m.price):'Своя подборка'}</span><small>${m.grams?`${Number(m.grams).toLocaleString('ru-RU')} г · `:''}на 1 гостя</small></button>`).join('')}</div>`:''}
|
${menus.length?`<div class="ct-banquet-packages" aria-label="Пакеты меню"><button type="button" data-banquet-package="all" aria-pressed="${packageId==='all'}"><b>Все блюда</b><small>${catalog.length} позиций</small></button>${menus.map(m=>`<button type="button" data-banquet-package="${esc(m.id)}" aria-pressed="${packageId===m.id}"><b>${esc(m.name)}</b><span>${m.price?money(m.price):'Своя подборка'}</span><small>${m.grams?`${Number(m.grams).toLocaleString('ru-RU')} г · `:''}на 1 гостя</small></button>`).join('')}</div>`:''}
|
||||||
|
${catalog.some(i=>i.demo&&i.ttk)?'<p class="ct-banquet-estimate">Учебные блюда: вес и цена указаны на одну порцию. Откройте ТТК, чтобы посмотреть состав и расход продуктов для склада и закупки.</p>':''}
|
||||||
${estimates?'<p class="ct-banquet-estimate">≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.</p>':''}
|
${estimates?'<p class="ct-banquet-estimate">≈ Веса и цены отдельных блюд рассчитаны приблизительно по общей стоимости меню. Их можно изменить в карточке блюда.</p>':''}
|
||||||
${menu?`<div class="ct-banquet-preset"><span><b>${esc(menu.name)}</b> · горячее — одно блюдо на выбор</span><button class="outline" type="button" data-banquet-preset="${esc(menu.id)}">Выбрать меню целиком</button></div>`:''}
|
${menu?`<div class="ct-banquet-preset"><span><b>${esc(menu.name)}</b> · горячее — одно блюдо на выбор</span><button class="outline" type="button" data-banquet-preset="${esc(menu.id)}">Выбрать меню целиком</button></div>`:''}
|
||||||
<div class="ct-banquet-groups" role="group" aria-label="Разделы банкетного меню"><button type="button" data-banquet-group="all" aria-pressed="${groupId==='all'}">Все разделы <span>${candidates.length}</span></button>${available.map(([id,label])=>`<button type="button" data-banquet-group="${id}" aria-pressed="${groupId===id}">${label} <span>${candidates.filter(i=>group(i)===id).length}</span></button>`).join('')}</div>
|
<div class="ct-banquet-groups" role="group" aria-label="Разделы банкетного меню"><button type="button" data-banquet-group="all" aria-pressed="${groupId==='all'}">Все разделы <span>${candidates.length}</span></button>${available.map(([id,label])=>`<button type="button" data-banquet-group="${id}" aria-pressed="${groupId===id}">${label} <span>${candidates.filter(i=>group(i)===id).length}</span></button>`).join('')}</div>
|
||||||
<div class="ct-banquet-layout"><div class="ct-banquet-dishes">${visible.length?[...sections].map(([section,rows])=>`<section class="ct-banquet-section"><h3>${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'<small>Одно блюдо на выбор в каждом пакете</small>':''}</h3>${rows.map(i=>`<div class="ct-banquet-dish ${ids.has(String(i.id))?'selected':''}" data-banquet-row="${esc(i.id)}"><label><input type="checkbox" data-banquet-item="${esc(i.id)}" ${ids.has(String(i.id))?'checked':''}><span class="ct-banquet-name">${esc(i.name)}<small>${esc(i.banquet?.packageName||i.catalogSection||'')}${i.banquet?.estimated?' · ≈':''}</small></span><span class="ct-banquet-weight">${esc(i.weight||'—')}</span><b class="ct-banquet-price">${money(unitPrice(i,draft))}</b></label><button type="button" data-banquet-edit="${esc(i.id)}" aria-label="Изменить ${esc(i.name)}" title="Изменить блюдо">✎</button></div>`).join('')}</section>`).join(''):`<p class="catalog-empty">${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}</p>`}</div>
|
<div class="ct-banquet-layout"><div class="ct-banquet-dishes">${visible.length?[...sections].map(([section,rows])=>`<section class="ct-banquet-section"><h3>${esc(section)}${rows.some(i=>i.banquet?.choiceGroup)?'<small>Одно блюдо на выбор в каждом пакете</small>':''}</h3>${rows.map(i=>`<div class="ct-banquet-dish ${ids.has(String(i.id))?'selected':''}" data-banquet-row="${esc(i.id)}"><label><input type="checkbox" data-banquet-item="${esc(i.id)}" ${ids.has(String(i.id))?'checked':''}><span class="ct-banquet-name">${esc(i.name)}<small>${esc(i.banquet?.packageName||i.catalogSection||'')}${i.banquet?.estimated?' · ≈':''}</small></span><span class="ct-banquet-weight">${esc(i.weight||'—')}</span><b class="ct-banquet-price">${money(unitPrice(i,draft))}</b></label>${i.demo&&i.ttk?`<button type="button" data-banquet-ttk="${esc(i.id)}" aria-label="ТТК: ${esc(i.name)}" title="Открыть технологическую карту">ТТК</button>`:''}<button type="button" data-banquet-edit="${esc(i.id)}" aria-label="Изменить ${esc(i.name)}" title="Изменить блюдо">✎</button></div>`).join('')}</section>`).join(''):`<p class="catalog-empty">${catalog.length?'По вашему запросу блюда не найдены.':'В банкетном меню пока нет блюд. Добавьте свои позиции.'}</p>`}</div>
|
||||||
<aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`;
|
<aside class="ct-banquet-summary"><span class="ct-eyebrow">Ваше меню</span><h3>${selected.length?`${selected.length} блюд`:'Пока пусто'}</h3><label>Количество гостей<input type="number" min="1" max="10000" step="1" data-banquet-guests value="${count}"></label><div class="ct-banquet-metric"><span>На одного гостя</span><b data-banquet-per-guest>${money(sum.price)}</b><small data-banquet-weight>${sum.grams.toLocaleString('ru-RU')} г</small></div><div class="ct-banquet-total"><span>На всех гостей</span><b data-banquet-total>${money(sum.price*count)}</b><small data-banquet-total-weight>${(sum.grams*count/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})} кг</small></div><button class="primary" type="button" data-banquet-apply ${selected.length?'':'disabled'}>${hasExisting?'Обновить банкет в заказе':'Добавить меню в заказ'}</button>${hasExisting?'<small class="ct-banquet-help">Обновятся только банкетные блюда. Остальные позиции заказа сохранятся.</small>':''}${selected.length?`<details><summary>Выбрано: ${selected.length}</summary><ul>${selected.map(i=>`<li>${esc(i.name)}</li>`).join('')}</ul></details><button class="ct-banquet-clear" type="button" data-banquet-clear>Снять выбор блюд</button>`:'<p class="ct-banquet-help">Блюда из разных разделов остаются в общей подборке.</p>'}</aside></div></div>`;
|
||||||
}
|
}
|
||||||
function bind(root,context){
|
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-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-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-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));
|
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 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()};
|
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('ctBanquetPackage').value=item?.banquet?.packageName||'';
|
||||||
document.getElementById('ctBanquetEstimated').checked=item?.banquet?.estimated===true;
|
document.getElementById('ctBanquetEstimated').checked=item?.banquet?.estimated===true;
|
||||||
document.getElementById('ctBanquetPackageNames').innerHTML=packages(catalog).map(m=>`<option value="${esc(m.name)}"></option>`).join('');
|
document.getElementById('ctBanquetPackageNames').innerHTML=packages(catalog).map(m=>`<option value="${esc(m.name)}"></option>`).join('');
|
||||||
let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Фрукты и ягоды','Хлеб и масло'].map(s=>`<option value="${s}"></option>`).join('');extra.appendChild(list)}
|
let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Гарниры','Десерты','Фрукты и ягоды','Хлеб и масло'].map(s=>`<option value="${s}"></option>`).join('');extra.appendChild(list)}
|
||||||
document.getElementById('banquetSection')?.setAttribute('list',list.id);
|
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}}
|
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=`
|
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}}
|
@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 .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}
|
#new.sun-banquet-active #sunCatalogSearchV1775,#new.sun-banquet-active .sun-catalog-view-switch{display:none!important}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
(()=>{
|
(()=>{
|
||||||
'use strict';
|
'use strict';
|
||||||
const VERSION='17.7.3';
|
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}};
|
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(){
|
function installAuthBoot(){
|
||||||
|
|||||||
@ -32,13 +32,14 @@
|
|||||||
const items=catalog().filter(b=>b.demo&&b.ttk),active=installed()&&items.length>0;
|
const items=catalog().filter(b=>b.demo&&b.ttk),active=installed()&&items.length>0;
|
||||||
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;}
|
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);}
|
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;
|
if(card.dataset.signature===signature)return;card.dataset.signature=signature;
|
||||||
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Демонстрационная база</strong><p>${items.length} боксов с фото и ТТК · учебные остатки и поставщики</p></div><span class="ct-demo-badge">Можно пробовать</span></div>
|
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Демонстрационная база</strong><p>${items.filter(b=>Number(b.category)!==6).length} боксов с фото · ${items.filter(b=>Number(b.category)===6).length} банкетных блюд · ТТК, учебные остатки и поставщики</p></div><span class="ct-demo-badge">Можно пробовать</span></div>
|
||||||
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p>
|
<p>Создайте пробный заказ, посмотрите нехватку в закупках, оформите приход на складе и спишите продукты по заказу. Все данные можно редактировать.</p>
|
||||||
<div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div>
|
<div class="ct-demo-actions"><button class="primary" data-demo-order ${writable()?'':'disabled'}>Создать пробный заказ</button><button class="outline" data-demo-go="stock">Склад</button><button class="outline" data-demo-go="shopping">Закупки</button></div>
|
||||||
<details><summary>Технологические карты боксов</summary><div class="ct-demo-actions"><label>Бокс<select id="ctDemoBox">${items.map(b=>`<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</select></label><button class="outline" data-demo-ttk>Открыть ТТК</button></div></details><p id="ctDemoMessage" role="status" aria-live="polite"></p>`:
|
${status?.canUpgrade?'<button class="outline" data-demo-install>Добавить 18 банкетных блюд</button>':''}
|
||||||
`<div class="ct-demo-heading"><div><strong>Попробуйте приложение на готовых боксах</strong><p>10 боксов с фото и ТТК, 43 продукта, 5 учебных поставщиков и начальные остатки.</p></div></div><button class="primary" data-demo-install>Загрузить демо</button><p id="ctDemoMessage" role="status" aria-live="polite"></p>`;
|
<details><summary>Технологические карты боксов и блюд</summary><div class="ct-demo-actions"><label>Бокс или блюдо<select id="ctDemoBox">${[...new Set(items.map(b=>Number(b.category)===6?'Банкетное меню':'Боксы'))].map(label=>`<optgroup label="${label}">${items.filter(b=>(Number(b.category)===6?'Банкетное меню':'Боксы')===label).map(b=>`<option value="${esc(b.id)}">${esc(b.name)}</option>`).join('')}</optgroup>`).join('')}</select></label><button class="outline" data-demo-ttk>Открыть ТТК</button></div></details><p id="ctDemoMessage" role="status" aria-live="polite"></p>`:
|
||||||
|
`<div class="ct-demo-heading"><div><strong>Попробуйте приложение на готовом меню</strong><p>10 боксов с фото, 18 банкетных блюд с ТТК, 52 продукта, 5 учебных поставщиков и начальные остатки.</p></div></div><button class="primary" data-demo-install>Загрузить демо</button><p id="ctDemoMessage" role="status" aria-live="polite"></p>`;
|
||||||
card.querySelector('[data-demo-install]')?.addEventListener('click',install);
|
card.querySelector('[data-demo-install]')?.addEventListener('click',install);
|
||||||
card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder);
|
card.querySelector('[data-demo-order]')?.addEventListener('click',createOrder);
|
||||||
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
|
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
|
||||||
@ -47,7 +48,7 @@
|
|||||||
async function install(){
|
async function install(){
|
||||||
if(busy)return;busy=true;const btn=$('ctTrialDemo')?.querySelector('[data-demo-install]');if(btn)btn.disabled=true;
|
if(busy)return;busy=true;const btn=$('ctTrialDemo')?.querySelector('[data-demo-install]');if(btn)btn.disabled=true;
|
||||||
message('Загружаю демонстрационную базу…');
|
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||'Не удалось загрузить демо. Повторите попытку.');}
|
catch(error){message(error?.message||'Не удалось загрузить демо. Повторите попытку.');}
|
||||||
finally{busy=false;if(btn?.isConnected)btn.disabled=false;}
|
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';
|
$('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]));
|
const changed=JSON.stringify(box.ingredients)!==JSON.stringify(t.rows.map(r=>[r.name,r.gross,r.unit]));
|
||||||
dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div>
|
dialog.innerHTML=`<div class="ct-demo-heading"><div><small>${esc(t.number)} · ${esc(t.basis)}</small><h2>${esc(box.name)}</h2></div><button class="outline" data-ttk-close aria-label="Закрыть ТТК">×</button></div>
|
||||||
<p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · Цена бокса: <b>${money(box.price)}</b></p>
|
<p>${changed?'Исходный выход':'Выход'}: <b>${num(t.outputGrams)} г${Number(box.category)===6?' · 1 порция':t.pieces?` · ${num(t.pieces)} шт.`:''}</b> · ${Number(box.category)===6?'Цена порции':'Цена бокса'}: <b>${money(box.price)}</b></p>
|
||||||
${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''}
|
${changed?'<p class="ct-demo-notice">Состав изменён. Закупка и списание используют текущий состав из редактора. Ниже показана исходная учебная ТТК.</p>':''}
|
||||||
<div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div>
|
<div class="ct-demo-table"><table><thead><tr><th>Продукт / упаковка</th><th>Ед.</th><th>Брутто</th><th>Нетто</th><th>Цена за ед.</th><th>Сумма</th></tr></thead><tbody>${t.rows.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.unit)}</td><td>${num(r.gross)}</td><td>${num(r.net)}</td><td>${money(r.unitCost)}</td><td>${money(r.gross*r.unitCost)}</td></tr>`).join('')}</tbody></table></div>
|
||||||
<p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p>
|
<p><b>Продукты и упаковка: ${money(t.ingredientCost)}</b></p><p class="hint">Учебные закупочные цены. Работа, доставка и накладные расходы не включены. Расход со склада считается по брутто.</p>
|
||||||
<h3>Приготовление и сборка</h3><ol>${t.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p><b>Аллергены:</b> ${t.allergens.map(esc).join(', ')}.</p><p class="hint">${esc(t.note)}</p>
|
${t.waterGrams?`<p class="hint">В выходе учтено ${num(t.waterGrams)} г воды, поглощённой при приготовлении. Вода не списывается со склада.</p>`:''}
|
||||||
|
<h3>Приготовление и сборка</h3><ol>${t.steps.map(s=>`<li>${esc(s)}</li>`).join('')}</ol><p><b>Аллергены:</b> ${t.allergens.length?t.allergens.map(esc).join(', '):'в учебном составе не указаны; уточните по продуктам поставщика'}.</p><p class="hint">${esc(t.note)}</p>
|
||||||
<div class="ct-demo-actions"><button class="outline" data-ttk-edit>Изменить состав и цену</button><button class="primary" data-ttk-close>Закрыть</button></div>`;
|
<div class="ct-demo-actions"><button class="outline" data-ttk-edit>Изменить состав и цену</button><button class="primary" data-ttk-close>Закрыть</button></div>`;
|
||||||
document.body.append(dialog);dialog.querySelectorAll('[data-ttk-close]').forEach(b=>b.onclick=()=>dialog.close());
|
document.body.append(dialog);dialog.querySelectorAll('[data-ttk-close]').forEach(b=>b.onclick=()=>dialog.close());
|
||||||
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
|
dialog.querySelector('[data-ttk-edit]').onclick=()=>{dialog.close();window.editBox?.(box.id)};
|
||||||
|
|||||||
1873
public/demo/banquet-v1.json
Normal file
1873
public/demo/banquet-v1.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
|||||||
const CACHE='sun-catering-pwa-v96-20260918-single-login';
|
const CACHE='sun-catering-pwa-v97-20260918-trial-banquet';
|
||||||
const VERSION='20260918-single-login';
|
const VERSION='20260918-trial-banquet';
|
||||||
const CORE=[
|
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}`,
|
'./','./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}`,
|
`./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}`,
|
||||||
|
|||||||
102
supabase/migrations/20260918060000_trial_banquet.sql
Normal file
102
supabase/migrations/20260918060000_trial_banquet.sql
Normal file
File diff suppressed because one or more lines are too long
@ -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);
|
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 demoRow=await one('select * from public.sun_fetch_app_state($1)',[foreign]);
|
||||||
const training=demoRow.payload.storage;
|
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(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));
|
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]);
|
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');
|
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;
|
// Model an already used v1 trial: no banquet yet, edited prices, real balances and an order.
|
||||||
await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,filled,'test-existing-catalog',demoRow.revision]);
|
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],/уже заполнены/);
|
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');
|
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 * 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_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_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 * from public.sun_v17_clients_snapshot_v1773($1)',[ws],/Access denied/);
|
||||||
await denied('select public.sun_dev_dashboard()',[],/administrator/);
|
await denied('select public.sun_dev_dashboard()',[],/administrator/);
|
||||||
await actor(ids[3]);
|
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]);
|
await actor(ids[1]);
|
||||||
assert.equal((await one('select public.caterium_trial_demo_status($1) as s',[ws])).s.eligible,false,'Solnce company excluded');
|
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_demo($1)',[ws],/Демонстрационная база доступна/);
|
||||||
|
await denied('select public.caterium_install_trial_banquet($1)',[ws],/Демонстрационная база доступна/);
|
||||||
await db.exec('reset role');
|
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;
|
const devWs=(await one("insert into public.sun_workspaces(name,created_by) values('Own developer test',$1) returning id",[ids[0]])).id;
|
||||||
await q("insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions) values($1,$2,'admin',true,public.sun_role_default_permissions('admin'))",[devWs,ids[0]]);
|
await q("insert into public.sun_workspace_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]);
|
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;
|
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.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/);
|
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');
|
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();
|
await db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent')
|
|||||||
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
|
check((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(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([...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(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-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(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('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("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');
|
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(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.version===`v${pkg.version}`,'release manifest version matches package.json');
|
||||||
check(releaseManifest.channel==='production','release manifest channel is production');
|
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(['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('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');
|
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(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(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(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(index.includes('20260918-trial-banquet'),'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(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(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(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');
|
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');
|
||||||
|
|||||||
@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el
|
|||||||
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
|
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));
|
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(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(!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-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(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('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("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');
|
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');
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import {test,expect} from '@playwright/test';
|
import {test,expect} from '@playwright/test';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import {demo} from '../ops/demo/trial-demo-data.mjs';
|
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})=>{
|
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());
|
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.
|
// 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));
|
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();
|
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();
|
await expect(page.locator('#ctTrialDemo')).toBeVisible();
|
||||||
}
|
}
|
||||||
test('demo TTK, order, shortages, receipt and one-time write-off work in the full app',async({page})=>{
|
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);
|
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})=>{
|
test('owner onboarding preserves server demo when saving the company profile',async({page})=>{
|
||||||
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));await page.goto('/index.html');
|
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!doctype html><html><body></body></html>'}));await page.goto('/index.html');
|
||||||
await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'});
|
await page.addScriptTag({url:'/core/sun-safe.js'});await page.addScriptTag({url:'/core/cloud-transport.js'});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user