Fill empty trial tabs with premium, drinks, tableware, extras and delivery

This commit is contained in:
pavlov346346-source 2026-09-18 08:26:21 +03:00
parent 18ff2b9c30
commit dac1a1de72
15 changed files with 1298 additions and 30 deletions

View File

@ -11,7 +11,7 @@
"serverReady": true,
"workspaceAutoDiscovery": true,
"invitesTemporarilyDisabled": false,
"pwaCache": "v97-20260918-trial-banquet",
"pwaCache": "v98-20260918-trial-extras",
"fullOfferDescriptions": true,
"dynamicOfferRows": true,
"pdfOfferDescriptionFix": true,

View File

@ -0,0 +1,16 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import {extrasDemo} from './trial-extras-data.mjs';
const root=new URL('../../',import.meta.url);
const original=fs.readFileSync(new URL('supabase/migrations/20260918060000_trial_banquet.sql',root),'utf8');
const block=name=>{const start=original.indexOf(`create or replace function public.${name}(`);assert.ok(start>=0);return original.slice(start,original.indexOf('end $fn$;',start)+10)};
let status=block('caterium_trial_demo_status');
const old="and coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1'";
assert.ok(status.includes(old));status=status.replace(old,"and (coalesce(v_storage#>>'{sunTrialDemoV1,v,banquetVersion}','')<>'1' or coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')<>'1')");
let banquet=block('caterium_install_trial_banquet');
banquet=banquet.replace("if not (v_status->>'canUpgrade')::boolean then return v_status; end if;","if coalesce((select payload#>>'{storage,sunTrialDemoV1,v,banquetVersion}' from public.sun_app_state where workspace_id=p_workspace),'')='1' then return v_status; end if;");
const install=block('caterium_install_trial_demo').replaceAll('return public.caterium_install_trial_banquet(p_workspace);','perform public.caterium_install_trial_banquet(p_workspace); return public.caterium_install_trial_extras(p_workspace);');
const template=fs.readFileSync(new URL('trial-extras.sql',import.meta.url),'utf8');
fs.writeFileSync(new URL('public/demo/extras-v1.json',root),JSON.stringify(extrasDemo,null,2)+'\n');
fs.writeFileSync(new URL('supabase/migrations/20260918083000_trial_extras.sql',root),template.replace('__EXTRAS_JSON__',JSON.stringify(extrasDemo)).replace('__STATUS_FUNCTION__',status).replace('__BANQUET_FUNCTION__',banquet).replace('__INSTALL_FUNCTION__',install));
console.log(`Built ${extrasDemo.items.length} items across five trial categories and ${extrasDemo.stock.length} stock products.`);

View File

@ -0,0 +1,36 @@
// Fictional items for empty trial tabs. Native category IDs must not follow visual order.
import {boxes} from './trial-demo-data.mjs';
const round=n=>Math.round(n*1000)/1000;
const mixes=[
['salmon-caprese','Премиум-сет «Лосось и капрезе»',3500,18,['salmon-cream','caprese']],
['meat-cheese','Премиум-сет «Мясо и сыры»',3900,0,['meat-assortment','cheese-fruit']],
['mini-buffet','Премиум-сет «Мини-фуршет»',2900,22,['bruschetta-tomato','mushroom-tartlet']]
];
const premium=mixes.map(([key,name,price,pieces,sourceIds],index)=>{
const sources=sourceIds.map(id=>boxes.find(b=>b.id===`demo-v1-${id}`)),map=new Map();
for(const b of sources)for(const r of b.ttk.rows){const old=map.get(r.productId)||{...r,gross:0,net:0};old.gross=round(old.gross+r.gross/2);old.net=round(old.net+r.net/2);map.set(r.productId,old)}
const rows=[...map.values()],outputGrams=Math.round(rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,0));
return {id:`demo-extras-v1-${key}`,name,category:5,catalogSection:'Демонстрационные премиум-сеты',price,pieces,weight:`${outputGrams} г`,photo:'',demo:true,
composition:sources.map(b=>'Половина стандартного бокса: '+b.name),ingredients:rows.map(r=>[r.name,r.gross,r.unit]),
ttk:{number:`ДЕМО-П${index+1}`,basis:'На 1 готовый премиум-сет',outputGrams,pieces,rows,
steps:['Приготовить две мини-подборки в половинном объёме по указанным нормам.',...sources.flatMap(b=>b.ttk.steps.map(s=>b.name+': '+s)),'Уложить обе подборки в одну общую коробку.'],
allergens:[...new Set(sources.flatMap(b=>b.ttk.allergens))],ingredientCost:Math.round(rows.reduce((s,r)=>s+r.gross*r.unitCost,0)*100)/100,note:'Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет.'}};
});
const specs=[
// key, category, sale name, sale price, stock name, purchase cost, unit, supplier, description
['water',3,'Вода негазированная, 0,5 л',90,'Демо: вода негазированная 0,5 л',35,'шт.','grocery','Одна бутылка 0,5 л'],
['sparkling',3,'Вода газированная, 0,5 л',100,'Демо: вода газированная 0,5 л',40,'шт.','grocery','Одна бутылка 0,5 л'],
['juice',3,'Яблочный сок, 1 л',220,'Демо: сок яблочный 1 л',110,'шт.','grocery','Одна упаковка 1 л'],
['mors',3,'Клюквенный морс, 1 л',280,'Демо: морс клюквенный 1 л',140,'шт.','grocery','Одна бутылка готового морса 1 л'],
['plate',1,'Тарелка одноразовая, 1 шт.',25,'Демо: тарелка одноразовая',12,'шт.','pack','Одна сервировочная тарелка'],
['fork',1,'Вилка одноразовая, 1 шт.',10,'Демо: вилка одноразовая',4,'шт.','pack','Одна вилка'],
['glass',1,'Стакан одноразовый, 250 мл',15,'Демо: стакан одноразовый 250 мл',6,'шт.','pack','Один стакан'],
['napkin',1,'Салфетка сервировочная, 1 шт.',10,'Демо: салфетка сервировочная',3,'шт.','pack','Одна бумажная салфетка'],
['ice',2,'Лёд пищевой, пакет 1 кг',180,'Демо: лёд пищевой',70,'кг','grocery','Один пакет пищевого льда, 1 кг'],
['tablecloth',2,'Скатерть одноразовая, 1 шт.',250,'Демо: скатерть одноразовая',110,'шт.','pack','Одна одноразовая скатерть 120 × 180 см'],
['serving-kit',2,'Набор для подачи: щипцы и лопатка',190,'Демо: набор щипцы и лопатка',85,'шт.','pack','Один комплект одноразовых приборов для подачи']
];
const stock=specs.map(([key,category,name,price,productName,cost,unit,supplier])=>({id:`demo-extras-v1-stock-${key}`,name:productName,cost,unit,qty:0,min:unit==='кг'?1:10,supplierId:`demo-v1-supplier-${supplier}`,lastPurchaseDate:'',lastPurchasePrice:0}));
const packaged=specs.map(([key,category,name,price,productName,cost,unit,supplier,description])=>({id:`demo-extras-v1-${key}`,name,category,catalogSection:({1:'Одноразовая посуда',2:'Для сервировки',3:'Безалкогольные напитки'})[category],price,pieces:1,weight:key==='ice'?'1 кг':'',photo:'',composition:[description],ingredients:[[productName,1,unit]],demo:true}));
const delivery=[['city','Доставка по городу',600,'Учебный тариф за один адрес в пределах города.'],['outer','Доставка за город',1200,'Учебный тариф за один адрес в ближайшем пригороде.']].map(([key,name,price,note])=>({id:`demo-extras-v1-delivery-${key}`,name,category:4,catalogSection:'Учебные тарифы доставки',price,pieces:0,weight:'',photo:'',composition:[note],ingredients:[],demo:true}));
export const extrasDemo={version:1,items:[...premium,...packaged,...delivery],stock};

48
ops/demo/trial-extras.sql Normal file
View File

@ -0,0 +1,48 @@
-- Generated by ops/demo/build-trial-extras.mjs. Fill empty trial categories only.
begin;
create or replace function public.caterium_trial_extras_seed_v1()
returns jsonb language sql immutable security definer set search_path=public,pg_temp
as $fn$ select $demo$__EXTRAS_JSON__$demo$::jsonb $fn$;
revoke all on function public.caterium_trial_extras_seed_v1() from public,anon,authenticated;
__STATUS_FUNCTION__
create or replace function public.caterium_install_trial_extras(p_workspace uuid)
returns jsonb language plpgsql security definer set search_path=public,pg_temp
as $fn$
declare
v_state public.sun_app_state%rowtype; v_status jsonb; v_storage jsonb; v_seed jsonb;
v_catalog jsonb; v_stock jsonb; v_added jsonb; v_products jsonb; v_marker jsonb;
begin
if auth.uid() is null or public.sun_member_role(p_workspace) is distinct from 'admin' then raise exception 'Administrator membership required'; end if;
perform 1 from public.sun_workspaces where id=p_workspace for update;
v_status:=public.caterium_trial_demo_status(p_workspace);
if not (v_status->>'eligible')::boolean then raise exception 'Демонстрационная база доступна в пробной или собственной тестовой компании разработчика'; end if;
if not (v_status->>'installed')::boolean then raise exception 'Сначала установите демонстрационную базу'; end if;
select * into v_state from public.sun_app_state where workspace_id=p_workspace for update;
v_storage:=v_state.payload->'storage';
if coalesce(v_storage#>>'{sunTrialDemoV1,v,extrasVersion}','')='1' then return v_status; end if;
v_catalog:=coalesce(v_storage#>'{sunBoxes,v}','[]'::jsonb);v_stock:=coalesce(v_storage#>'{sunStock,v}','[]'::jsonb);
if jsonb_typeof(v_catalog)<>'array' or jsonb_typeof(v_stock)<>'array' then raise exception 'Неверный формат каталога или склада. Данные сохранены без изменений'; end if;
v_seed:=public.caterium_trial_extras_seed_v1();
select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_added
from jsonb_array_elements(v_seed->'items') with ordinality as x(n,ord)
where not exists(select 1 from jsonb_array_elements(v_catalog) old where coalesce(old->>'category','0')=n->>'category' or old->>'id'=n->>'id');
select coalesce(jsonb_agg(n order by ord),'[]'::jsonb) into v_products
from jsonb_array_elements(v_seed->'stock') with ordinality as x(n,ord)
where not exists(select 1 from jsonb_array_elements(v_stock) old where old->>'id'=n->>'id' or (old->>'name'=n->>'name' and old->>'unit'=n->>'unit'))
and exists(select 1 from jsonb_array_elements(v_added) i cross join lateral jsonb_array_elements(i->'ingredients') r where r->>0=n->>'name' and r->>2=n->>'unit');
v_marker:=(v_storage#>'{sunTrialDemoV1,v}')||jsonb_build_object('extrasVersion',1,'extrasInstalledAt',now());
v_storage:=v_storage||jsonb_build_object('sunBoxes',jsonb_build_object('t','j','v',v_catalog||v_added),'sunStock',jsonb_build_object('t','j','v',v_stock||v_products),'sunTrialDemoV1',jsonb_build_object('t','j','v',v_marker));
perform public.sun_save_app_state_v17(p_workspace,jsonb_set(v_state.payload,'{storage}',v_storage),'trial-extras-v1',v_state.revision);
insert into public.sun_platform_audit_events(actor_user_id,action,target_workspace_id,details)
values(auth.uid(),'trial_demo.extras_install',p_workspace,jsonb_build_object('version',1,'items',jsonb_array_length(v_added)));
return public.caterium_trial_demo_status(p_workspace);
end $fn$;
revoke all on function public.caterium_install_trial_extras(uuid) from public,anon;
grant execute on function public.caterium_install_trial_extras(uuid) to authenticated;
__BANQUET_FUNCTION__
__INSTALL_FUNCTION__
notify pgrst,'reload schema';
commit;

View File

@ -13,7 +13,7 @@
"test:e2e": "playwright test --config=tests/playwright.config.mjs",
"test": "npm run check:deploy",
"deploy:cloudflare-backup": "wrangler deploy",
"build:demo": "node ops/demo/build-trial-demo.mjs && node ops/demo/build-trial-banquet.mjs"
"build:demo": "node ops/demo/build-trial-demo.mjs && node ops/demo/build-trial-banquet.mjs && node ops/demo/build-trial-extras.mjs"
},
"devDependencies": {
"@electric-sql/pglite": "0.5.8",

View File

@ -1,7 +1,7 @@
(()=>{
'use strict';
const VERSION='17.7.3';
const RELEASE='20260918-trial-banquet';
const RELEASE='20260918-trial-extras';
const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}};
function installAuthBoot(){

View File

@ -29,17 +29,17 @@
}
function render(){
const host=$('new');if(!host)return;
const items=catalog().filter(b=>b.demo&&b.ttk),active=installed()&&items.length>0;
const demoItems=catalog().filter(b=>b.demo),items=demoItems.filter(b=>b.ttk),active=installed()&&demoItems.length>0;
if(!signedIn()||cloud()?.isSupportMode?.()||(!active&&!status?.canInstall)){$('ctTrialDemo')?.remove();return;}
let card=$('ctTrialDemo');if(!card){card=document.createElement('section');card.id='ctTrialDemo';card.className='card';host.prepend(card);}
const signature=JSON.stringify({active,items:items.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()});
const signature=JSON.stringify({active,items:demoItems.map(x=>[x.id,x.name]),upgrade:status?.canUpgrade,can:writable()});
if(card.dataset.signature===signature)return;card.dataset.signature=signature;
card.innerHTML=active?`<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>
card.innerHTML=active?`<div class="ct-demo-heading"><div><strong>Демонстрационная база</strong><p>${demoItems.length} тестовых позиций · готовые составы, учебные цены и поставщики</p></div><span class="ct-demo-badge">Можно пробовать</span></div>
<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>
${status?.canUpgrade?'<button class="outline" data-demo-install>Добавить 18 банкетных блюд</button>':''}
<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>`;
${status?.canUpgrade?'<button class="outline" data-demo-install>Дополнить пустые вкладки демо</button>':''}
<details><summary>Технологические карты боксов и блюд</summary><div class="ct-demo-actions"><label>Бокс или блюдо<select id="ctDemoBox">${[...new Set(items.map(b=>Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы'))].map(label=>`<optgroup label="${label}">${items.filter(b=>(Number(b.category)===6?'Банкетное меню':Number(b.category)===5?'Премиум':'Боксы')===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>Боксы и премиум-сеты, 18 банкетных блюд, напитки, посуда, дополнения и доставка. Учебные цены, составы и поставщики для проверки заказа, склада и закупки.</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-order]')?.addEventListener('click',createOrder);
card.querySelector('[data-demo-ttk]')?.addEventListener('click',()=>showTTK($('ctDemoBox').value));
@ -48,7 +48,7 @@
async function install(){
if(busy)return;busy=true;const btn=$('ctTrialDemo')?.querySelector('[data-demo-install]');if(btn)btn.disabled=true;
message('Загружаю демонстрационную базу…');
try{await cloud().installTrialDemo();status=null;statusWorkspace='';render();message('Готово. Выберите боксы или откройте «Банкетное меню».');await refreshStatus();}
try{await cloud().installTrialDemo();status=null;statusWorkspace='';render();message('Готово. Выберите позиции в нужной вкладке каталога.');await refreshStatus();}
catch(error){message(error?.message||'Не удалось загрузить демо. Повторите попытку.');}
finally{busy=false;if(btn?.isConnected)btn.disabled=false;}
}

983
public/demo/extras-v1.json Normal file
View File

@ -0,0 +1,983 @@
{
"version": 1,
"items": [
{
"id": "demo-extras-v1-salmon-caprese",
"name": "Премиум-сет «Лосось и капрезе»",
"category": 5,
"catalogSection": "Демонстрационные премиум-сеты",
"price": 3500,
"pieces": 18,
"weight": "710 г",
"photo": "",
"demo": true,
"composition": [
"Половина стандартного бокса: Канапе с лососем и сливочным сыром",
"Половина стандартного бокса: Канапе «Капрезе»"
],
"ingredients": [
[
"Хлеб ржаной",
0.12,
"кг"
],
[
"Лосось слабосолёный",
0.12,
"кг"
],
[
"Сыр сливочный",
0.08,
"кг"
],
[
"Огурцы",
0.042,
"кг"
],
[
"Укроп",
0.005,
"кг"
],
[
"Коробка для кейтеринга",
1,
"шт."
],
[
"Моцарелла мини",
0.18,
"кг"
],
[
"Томаты черри",
0.154,
"кг"
],
[
"Базилик",
0.011,
"кг"
],
[
"Масло оливковое",
0.02,
"кг"
],
[
"Шпажка бамбуковая",
10,
"шт."
]
],
"ttk": {
"number": "ДЕМО-П1",
"basis": "На 1 готовый премиум-сет",
"outputGrams": 710,
"pieces": 18,
"rows": [
{
"productId": "demo-v1-stock-rye",
"name": "Хлеб ржаной",
"unit": "кг",
"gross": 0.12,
"net": 0.12,
"unitCost": 220
},
{
"productId": "demo-v1-stock-salmon",
"name": "Лосось слабосолёный",
"unit": "кг",
"gross": 0.12,
"net": 0.12,
"unitCost": 2400
},
{
"productId": "demo-v1-stock-cream",
"name": "Сыр сливочный",
"unit": "кг",
"gross": 0.08,
"net": 0.08,
"unitCost": 850
},
{
"productId": "demo-v1-stock-cucumber",
"name": "Огурцы",
"unit": "кг",
"gross": 0.042,
"net": 0.036,
"unitCost": 220
},
{
"productId": "demo-v1-stock-dill",
"name": "Укроп",
"unit": "кг",
"gross": 0.005,
"net": 0.004,
"unitCost": 700
},
{
"productId": "demo-v1-stock-box",
"name": "Коробка для кейтеринга",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 65
},
{
"productId": "demo-v1-stock-mozzarella",
"name": "Моцарелла мини",
"unit": "кг",
"gross": 0.18,
"net": 0.18,
"unitCost": 950
},
{
"productId": "demo-v1-stock-cherry",
"name": "Томаты черри",
"unit": "кг",
"gross": 0.154,
"net": 0.14,
"unitCost": 450
},
{
"productId": "demo-v1-stock-basil",
"name": "Базилик",
"unit": "кг",
"gross": 0.011,
"net": 0.01,
"unitCost": 1500
},
{
"productId": "demo-v1-stock-oil",
"name": "Масло оливковое",
"unit": "кг",
"gross": 0.02,
"net": 0.02,
"unitCost": 1100
},
{
"productId": "demo-v1-stock-skewer",
"name": "Шпажка бамбуковая",
"unit": "шт.",
"gross": 10,
"net": 10,
"unitCost": 2
}
],
"steps": [
"Приготовить две мини-подборки в половинном объёме по указанным нормам.",
"Канапе с лососем и сливочным сыром: Вырезать из ржаного хлеба 16 основ.",
"Канапе с лососем и сливочным сыром: Нанести сливочный сыр, добавить ломтики огурца и лосося.",
"Канапе с лососем и сливочным сыром: Украсить укропом и уложить в коробку.",
"Канапе «Капрезе»: Подготовить черри и базилик, обсушить моцареллу.",
"Канапе «Капрезе»: Собрать 20 шпажек, распределив ингредиенты поровну.",
"Канапе «Капрезе»: Добавить оливковое масло, уложить в коробку.",
"Уложить обе подборки в одну общую коробку."
],
"allergens": [
"Рыба",
"Молоко",
"Глютен"
],
"ingredientCost": 758.94,
"note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."
}
},
{
"id": "demo-extras-v1-meat-cheese",
"name": "Премиум-сет «Мясо и сыры»",
"category": 5,
"catalogSection": "Демонстрационные премиум-сеты",
"price": 3900,
"pieces": 0,
"weight": "750 г",
"photo": "",
"demo": true,
"composition": [
"Половина стандартного бокса: Мясное ассорти с корнишонами",
"Половина стандартного бокса: Сырный бокс с виноградом и орехами"
],
"ingredients": [
[
"Салями",
0.09,
"кг"
],
[
"Ростбиф готовый",
0.11,
"кг"
],
[
"Ветчина из индейки",
0.075,
"кг"
],
[
"Корнишоны",
0.045,
"кг"
],
[
"Маслины без косточек",
0.023,
"кг"
],
[
"Горчица",
0.015,
"кг"
],
[
"Соусник",
1,
"шт."
],
[
"Коробка для кейтеринга",
1,
"шт."
],
[
"Сыр бри",
0.1,
"кг"
],
[
"Сыр гауда",
0.09,
"кг"
],
[
"Сыр с голубой плесенью",
0.06,
"кг"
],
[
"Виноград",
0.088,
"кг"
],
[
"Орех грецкий очищенный",
0.025,
"кг"
],
[
"Мёд",
0.02,
"кг"
],
[
"Крекер",
0.025,
"кг"
]
],
"ttk": {
"number": "ДЕМО-П2",
"basis": "На 1 готовый премиум-сет",
"outputGrams": 750,
"pieces": 0,
"rows": [
{
"productId": "demo-v1-stock-salami",
"name": "Салями",
"unit": "кг",
"gross": 0.09,
"net": 0.09,
"unitCost": 1400
},
{
"productId": "demo-v1-stock-beef",
"name": "Ростбиф готовый",
"unit": "кг",
"gross": 0.11,
"net": 0.11,
"unitCost": 2000
},
{
"productId": "demo-v1-stock-turkey",
"name": "Ветчина из индейки",
"unit": "кг",
"gross": 0.075,
"net": 0.075,
"unitCost": 1000
},
{
"productId": "demo-v1-stock-pickle",
"name": "Корнишоны",
"unit": "кг",
"gross": 0.045,
"net": 0.04,
"unitCost": 400
},
{
"productId": "demo-v1-stock-olive",
"name": "Маслины без косточек",
"unit": "кг",
"gross": 0.023,
"net": 0.02,
"unitCost": 650
},
{
"productId": "demo-v1-stock-mustard",
"name": "Горчица",
"unit": "кг",
"gross": 0.015,
"net": 0.015,
"unitCost": 350
},
{
"productId": "demo-v1-stock-sauce-cup",
"name": "Соусник",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 7
},
{
"productId": "demo-v1-stock-box",
"name": "Коробка для кейтеринга",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 65
},
{
"productId": "demo-v1-stock-brie",
"name": "Сыр бри",
"unit": "кг",
"gross": 0.1,
"net": 0.1,
"unitCost": 1600
},
{
"productId": "demo-v1-stock-gouda",
"name": "Сыр гауда",
"unit": "кг",
"gross": 0.09,
"net": 0.09,
"unitCost": 900
},
{
"productId": "demo-v1-stock-blue",
"name": "Сыр с голубой плесенью",
"unit": "кг",
"gross": 0.06,
"net": 0.06,
"unitCost": 1700
},
{
"productId": "demo-v1-stock-grape",
"name": "Виноград",
"unit": "кг",
"gross": 0.088,
"net": 0.08,
"unitCost": 420
},
{
"productId": "demo-v1-stock-walnut",
"name": "Орех грецкий очищенный",
"unit": "кг",
"gross": 0.025,
"net": 0.025,
"unitCost": 1000
},
{
"productId": "demo-v1-stock-honey",
"name": "Мёд",
"unit": "кг",
"gross": 0.02,
"net": 0.02,
"unitCost": 800
},
{
"productId": "demo-v1-stock-cracker",
"name": "Крекер",
"unit": "кг",
"gross": 0.025,
"net": 0.025,
"unitCost": 500
}
],
"steps": [
"Приготовить две мини-подборки в половинном объёме по указанным нормам.",
"Мясное ассорти с корнишонами: Нарезать готовые мясные продукты тонкими ломтиками.",
"Мясное ассорти с корнишонами: Обсушить корнишоны и маслины, переложить горчицу в соусник.",
"Мясное ассорти с корнишонами: Разложить ассорти секциями, добавить гарниры.",
"Сырный бокс с виноградом и орехами: Нарезать сыры порционными кусочками.",
"Сырный бокс с виноградом и орехами: Подготовить виноград, переложить мёд в соусник.",
"Сырный бокс с виноградом и орехами: Разложить сыры, виноград, орехи и крекер отдельными секциями.",
"Уложить обе подборки в одну общую коробку."
],
"allergens": [
"Горчица",
"Молоко",
"Орехи",
"Глютен"
],
"ingredientCost": 964.66,
"note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."
}
},
{
"id": "demo-extras-v1-mini-buffet",
"name": "Премиум-сет «Мини-фуршет»",
"category": 5,
"catalogSection": "Демонстрационные премиум-сеты",
"price": 2900,
"pieces": 22,
"weight": "820 г",
"photo": "",
"demo": true,
"composition": [
"Половина стандартного бокса: Брускетты с томатами и базиликом",
"Половина стандартного бокса: Тарталетки с грибами и сыром"
],
"ingredients": [
[
"Багет",
0.24,
"кг"
],
[
"Томаты",
0.165,
"кг"
],
[
"Базилик",
0.013,
"кг"
],
[
"Масло оливковое",
0.028,
"кг"
],
[
"Коробка для кейтеринга",
1,
"шт."
],
[
"Тарталетки готовые",
0.1,
"кг"
],
[
"Шампиньоны",
0.22,
"кг"
],
[
"Сливки",
0.05,
"кг"
],
[
"Сыр гауда",
0.06,
"кг"
],
[
"Лук репчатый",
0.044,
"кг"
]
],
"ttk": {
"number": "ДЕМО-П3",
"basis": "На 1 готовый премиум-сет",
"outputGrams": 820,
"pieces": 22,
"rows": [
{
"productId": "demo-v1-stock-baguette",
"name": "Багет",
"unit": "кг",
"gross": 0.24,
"net": 0.24,
"unitCost": 240
},
{
"productId": "demo-v1-stock-tomato",
"name": "Томаты",
"unit": "кг",
"gross": 0.165,
"net": 0.15,
"unitCost": 260
},
{
"productId": "demo-v1-stock-basil",
"name": "Базилик",
"unit": "кг",
"gross": 0.013,
"net": 0.012,
"unitCost": 1500
},
{
"productId": "demo-v1-stock-oil",
"name": "Масло оливковое",
"unit": "кг",
"gross": 0.028,
"net": 0.028,
"unitCost": 1100
},
{
"productId": "demo-v1-stock-box",
"name": "Коробка для кейтеринга",
"unit": "шт.",
"gross": 1,
"net": 1,
"unitCost": 65
},
{
"productId": "demo-v1-stock-tartlet",
"name": "Тарталетки готовые",
"unit": "кг",
"gross": 0.1,
"net": 0.1,
"unitCost": 750
},
{
"productId": "demo-v1-stock-mushroom",
"name": "Шампиньоны",
"unit": "кг",
"gross": 0.22,
"net": 0.15,
"unitCost": 340
},
{
"productId": "demo-v1-stock-cooking-cream",
"name": "Сливки",
"unit": "кг",
"gross": 0.05,
"net": 0.05,
"unitCost": 500
},
{
"productId": "demo-v1-stock-gouda",
"name": "Сыр гауда",
"unit": "кг",
"gross": 0.06,
"net": 0.06,
"unitCost": 900
},
{
"productId": "demo-v1-stock-onion",
"name": "Лук репчатый",
"unit": "кг",
"gross": 0.044,
"net": 0.03,
"unitCost": 80
}
],
"steps": [
"Приготовить две мини-подборки в половинном объёме по указанным нормам.",
"Брускетты с томатами и базиликом: Нарезать багет на 24 одинаковых ломтика и подсушить.",
"Брускетты с томатами и базиликом: Подготовить томаты и базилик, смешать с маслом.",
"Брускетты с томатами и базиликом: Распределить начинку по ломтикам, уложить в коробку.",
"Тарталетки с грибами и сыром: Подготовить и обжарить лук с грибами на масле; в расчёте учтены потери массы.",
"Тарталетки с грибами и сыром: Добавить сливки и распределить начинку по 20 тарталеткам.",
"Тарталетки с грибами и сыром: Добавить сыр, запечь до расплавления и упаковать после охлаждения.",
"Уложить обе подборки в одну общую коробку."
],
"allergens": [
"Глютен",
"Молоко"
],
"ingredientCost": 448.12,
"note": "Учебный сет и примерные цены. Все количества в таблице рассчитаны на один сет."
}
},
{
"id": "demo-extras-v1-water",
"name": "Вода негазированная, 0,5 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 90,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна бутылка 0,5 л"
],
"ingredients": [
[
"Демо: вода негазированная 0,5 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-sparkling",
"name": "Вода газированная, 0,5 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 100,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна бутылка 0,5 л"
],
"ingredients": [
[
"Демо: вода газированная 0,5 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-juice",
"name": "Яблочный сок, 1 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 220,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна упаковка 1 л"
],
"ingredients": [
[
"Демо: сок яблочный 1 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-mors",
"name": "Клюквенный морс, 1 л",
"category": 3,
"catalogSection": "Безалкогольные напитки",
"price": 280,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна бутылка готового морса 1 л"
],
"ingredients": [
[
"Демо: морс клюквенный 1 л",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-plate",
"name": "Тарелка одноразовая, 1 шт.",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 25,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна сервировочная тарелка"
],
"ingredients": [
[
"Демо: тарелка одноразовая",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-fork",
"name": "Вилка одноразовая, 1 шт.",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 10,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна вилка"
],
"ingredients": [
[
"Демо: вилка одноразовая",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-glass",
"name": "Стакан одноразовый, 250 мл",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 15,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Один стакан"
],
"ingredients": [
[
"Демо: стакан одноразовый 250 мл",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-napkin",
"name": "Салфетка сервировочная, 1 шт.",
"category": 1,
"catalogSection": "Одноразовая посуда",
"price": 10,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна бумажная салфетка"
],
"ingredients": [
[
"Демо: салфетка сервировочная",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-ice",
"name": "Лёд пищевой, пакет 1 кг",
"category": 2,
"catalogSection": "Для сервировки",
"price": 180,
"pieces": 1,
"weight": "1 кг",
"photo": "",
"composition": [
"Один пакет пищевого льда, 1 кг"
],
"ingredients": [
[
"Демо: лёд пищевой",
1,
"кг"
]
],
"demo": true
},
{
"id": "demo-extras-v1-tablecloth",
"name": "Скатерть одноразовая, 1 шт.",
"category": 2,
"catalogSection": "Для сервировки",
"price": 250,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Одна одноразовая скатерть 120 × 180 см"
],
"ingredients": [
[
"Демо: скатерть одноразовая",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-serving-kit",
"name": "Набор для подачи: щипцы и лопатка",
"category": 2,
"catalogSection": "Для сервировки",
"price": 190,
"pieces": 1,
"weight": "",
"photo": "",
"composition": [
"Один комплект одноразовых приборов для подачи"
],
"ingredients": [
[
"Демо: набор щипцы и лопатка",
1,
"шт."
]
],
"demo": true
},
{
"id": "demo-extras-v1-delivery-city",
"name": "Доставка по городу",
"category": 4,
"catalogSection": "Учебные тарифы доставки",
"price": 600,
"pieces": 0,
"weight": "",
"photo": "",
"composition": [
"Учебный тариф за один адрес в пределах города."
],
"ingredients": [],
"demo": true
},
{
"id": "demo-extras-v1-delivery-outer",
"name": "Доставка за город",
"category": 4,
"catalogSection": "Учебные тарифы доставки",
"price": 1200,
"pieces": 0,
"weight": "",
"photo": "",
"composition": [
"Учебный тариф за один адрес в ближайшем пригороде."
],
"ingredients": [],
"demo": true
}
],
"stock": [
{
"id": "demo-extras-v1-stock-water",
"name": "Демо: вода негазированная 0,5 л",
"cost": 35,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-sparkling",
"name": "Демо: вода газированная 0,5 л",
"cost": 40,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-juice",
"name": "Демо: сок яблочный 1 л",
"cost": 110,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-mors",
"name": "Демо: морс клюквенный 1 л",
"cost": 140,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-plate",
"name": "Демо: тарелка одноразовая",
"cost": 12,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-fork",
"name": "Демо: вилка одноразовая",
"cost": 4,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-glass",
"name": "Демо: стакан одноразовый 250 мл",
"cost": 6,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-napkin",
"name": "Демо: салфетка сервировочная",
"cost": 3,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-ice",
"name": "Демо: лёд пищевой",
"cost": 70,
"unit": "кг",
"qty": 0,
"min": 1,
"supplierId": "demo-v1-supplier-grocery",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-tablecloth",
"name": "Демо: скатерть одноразовая",
"cost": 110,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
},
{
"id": "demo-extras-v1-stock-serving-kit",
"name": "Демо: набор щипцы и лопатка",
"cost": 85,
"unit": "шт.",
"qty": 0,
"min": 10,
"supplierId": "demo-v1-supplier-pack",
"lastPurchaseDate": "",
"lastPurchasePrice": 0
}
]
}

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
const CACHE='sun-catering-pwa-v97-20260918-trial-banquet';
const VERSION='20260918-trial-banquet';
const CACHE='sun-catering-pwa-v98-20260918-trial-extras';
const VERSION='20260918-trial-extras';
const CORE=[
'./','./index.html',`./core/proposal-layout.js?v=${VERSION}`,'./fonts/Manrope.ttf','./fonts/PlayfairDisplay.ttf','./fonts/PlayfairDisplay-Italic.ttf',`./core/trial-demo.js?v=${VERSION}`,`./core/cloud-transport.js?v=${VERSION}`,`./core/banquet-menu.js?v=${VERSION}`,`./core/access-policy.js?v=${VERSION}`,`./core/import-archive.js?v=${VERSION}`,`./core/company-branding.js?v=${VERSION}`,`./core/signature-offer-pdf-v18.js?v=${VERSION}`,`./core/brand-theme.js?v=${VERSION}`,
`./core/sun-safe.js?v=${VERSION}`,`./core/performance.js?v=${VERSION}`,`./core/account-center-v1780.js?v=${VERSION}`,`./core/login-signature-v1776.js?v=${VERSION}`,`./core/login-signature-v1776.css?v=${VERSION}`,`./core/data-layer-v1773.js?v=${VERSION}`,`./core/server-automation-v1770.js?v=${VERSION}`,`./core/hotfix-v1763.js?v=${VERSION}`,`./core/ops-ux-v1762.js?v=${VERSION}`,`./core/ux-fixes-v1764.js?v=${VERSION}`,`./core/pdf-engine.js?v=${VERSION}`,`./core/classic-offer-pdf-v1767.js?v=${VERSION}`,`./core/developer-console-v1768.js?v=${VERSION}`,`./core/offer-workspace-v1769.js?v=${VERSION}`,`./core/auth-security-v1774.js?v=${VERSION}`,`./core/order-enhancements-v1775.js?v=${VERSION}`,`./legacy/bootstrap.js?v=${VERSION}`,`./app-runtime.js?v=${VERSION}`,

File diff suppressed because one or more lines are too long

View File

@ -61,21 +61,23 @@ await actor(ids[2]);const foreign=(await one('select public.sun_create_workspace
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).payload.storage.sunPdfBrandLogoV1,undefined);
const demoRow=await one('select * from public.sun_fetch_app_state($1)',[foreign]);
const training=demoRow.payload.storage;
assert.equal(training.sunBoxes.v.length,28);assert.equal(training.sunStock.v.length,52);assert.equal(training.sunSuppliers.v.length,5);
assert.equal(training.sunBoxes.v.length,44);assert.equal(training.sunStock.v.length,63);assert.equal(training.sunSuppliers.v.length,5);
assert.deepEqual([0,1,2,3,4,5,6].map(c=>training.sunBoxes.v.filter(b=>b.category===c).length),[10,4,3,4,2,3,18]);
assert.equal(training.sunBoxes.v.filter(b=>b.category===6).length,18);assert.equal(training.sunTrialDemoV1.v.banquetVersion,1);
assert.equal(training.sunOrders,undefined);assert.equal(training.sunTrialDemoV1.v.version,1);
assert.equal((await one('select public.sun_v17_entity_snapshot($1) as s',[foreign])).s.catalog.length,28);
assert.equal((await one('select public.sun_v17_entity_snapshot($1) as s',[foreign])).s.catalog.length,44);
assert.ok(training.sunStockMoves.v.length>0);assert.ok(training.sunStock.v.some(p=>p.qty===0));
for(const b of training.sunBoxes.v){assert.ok(b.ttk.ingredientCost>0);assert.equal(b.ttk.outputGrams,Math.round(b.ttk.rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,b.ttk.waterGrams||0)));for(const r of b.ttk.rows){assert.ok(r.gross>=r.net);assert.ok(training.sunStock.v.some(p=>p.id===r.productId&&p.name===r.name&&p.unit===r.unit&&p.cost===r.unitCost));}}
for(const b of training.sunBoxes.v){for(const [name,qty,unit] of b.ingredients){assert.ok(qty>0);assert.ok(training.sunStock.v.some(p=>p.name===name&&p.unit===unit));}if(!b.ttk)continue;assert.ok(b.ttk.ingredientCost>0);assert.equal(b.ttk.outputGrams,Math.round(b.ttk.rows.filter(r=>r.unit==='кг').reduce((s,r)=>s+r.net*1000,b.ttk.waterGrams||0)));for(const r of b.ttk.rows){assert.ok(r.gross>=r.net);assert.ok(training.sunStock.v.some(p=>p.id===r.productId&&p.name===r.name&&p.unit===r.unit&&p.cost===r.unitCost));}}
await q('select public.caterium_install_trial_demo($1)',[foreign]);
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,demoRow.revision,'idempotent demo install does not overwrite');
// Model an already used v1 trial: no banquet yet, edited prices, real balances and an order.
const legacy=structuredClone(demoRow.payload);
legacy.storage.sunBoxes.v=legacy.storage.sunBoxes.v.filter(b=>b.category!==6);
legacy.storage.sunBoxes.v=legacy.storage.sunBoxes.v.filter(b=>b.category===0);
legacy.storage.sunBoxes.v[0].price=4321;
legacy.storage.sunStock.v=legacy.storage.sunStock.v.filter(p=>!p.id.startsWith('demo-banquet-'));
legacy.storage.sunStock.v=legacy.storage.sunStock.v.filter(p=>p.id.startsWith('demo-v1-stock-'));
legacy.storage.sunStock.v[0].qty=7.123;
delete legacy.storage.sunTrialDemoV1.v.banquetVersion;
delete legacy.storage.sunTrialDemoV1.v.extrasVersion;
legacy.storage.sunOrders={t:'j',v:[{id:91,event:'Keep my order',date:'2099-12-20',time:'12:00',status:'Новый',lines:[],total:4321,prepayment:100,balance:4221,autoCompletionDisabled:true}]};
let upgraded=await one('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,legacy,'legacy-trial-fixture',demoRow.revision]);
assert.equal((await one('select public.caterium_trial_demo_status($1) as s',[foreign])).s.canUpgrade,true);
@ -84,18 +86,38 @@ upgraded=await one('select * from public.sun_fetch_app_state($1)',[foreign]);
assert.deepEqual(upgraded.payload.storage.sunBoxes.v.slice(0,10),legacy.storage.sunBoxes.v);
assert.deepEqual(upgraded.payload.storage.sunStock.v.slice(0,43),legacy.storage.sunStock.v);
for(const key of ['sunOrders','sunStockMoves','sunSuppliers'])assert.deepEqual(upgraded.payload.storage[key],legacy.storage[key]);
assert.equal(upgraded.payload.storage.sunBoxes.v.length,28);
assert.equal(upgraded.payload.storage.sunBoxes.v.length,44);
await q('select public.caterium_install_trial_banquet($1)',[foreign]);
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,upgraded.revision);
// Existing custom drink/ware tabs must remain untouched, with no unused demo stock.
const partial=structuredClone(upgraded.payload);
partial.storage.sunBoxes.v=partial.storage.sunBoxes.v.filter(b=>!b.id.startsWith('demo-extras-'));
partial.storage.sunStock.v=partial.storage.sunStock.v.filter(p=>!p.id.startsWith('demo-extras-'));
delete partial.storage.sunTrialDemoV1.v.extrasVersion;
const custom=[{id:'own-drink',name:'My drink',category:3,price:123,ingredients:[]},{id:'own-plate',name:'My plate',category:1,price:456,ingredients:[]}];
partial.storage.sunBoxes.v.push(...custom);
await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,partial,'partly-filled-trial',upgraded.revision]);
await q('select public.caterium_install_trial_demo($1)',[foreign]);
upgraded=await one('select * from public.sun_fetch_app_state($1)',[foreign]);
assert.equal(upgraded.payload.storage.sunBoxes.v.length,38);
assert.deepEqual(upgraded.payload.storage.sunBoxes.v.filter(b=>b.category===1||b.category===3),custom);
assert.equal(upgraded.payload.storage.sunStock.v.length,55);
assert.deepEqual(upgraded.payload.storage.sunBoxes.v.slice(0,30),partial.storage.sunBoxes.v);
assert.deepEqual(upgraded.payload.storage.sunStock.v.slice(0,52),partial.storage.sunStock.v);
assert.equal(upgraded.payload.storage.sunTrialDemoV1.v.banquetInstalledAt,partial.storage.sunTrialDemoV1.v.banquetInstalledAt,'already upgraded banquet untouched');
await q('select public.caterium_install_trial_extras($1)',[foreign]);
assert.equal((await one('select * from public.sun_fetch_app_state($1)',[foreign])).revision,upgraded.revision);
const filled=structuredClone(upgraded.payload);delete filled.storage.sunTrialDemoV1;
await q('select * from public.sun_save_app_state_v17($1,$2,$3,$4)',[foreign,filled,'test-existing-catalog',upgraded.revision]);
await denied('select public.caterium_install_trial_banquet($1)',[foreign],/Сначала установите/);
await denied('select public.caterium_install_trial_extras($1)',[foreign],/Сначала установите/);
await denied('select public.caterium_install_trial_demo($1)',[foreign],/уже заполнены/);
assert.equal((await one('select public.caterium_my_sidebar_brand() as b')).b.variant,'caterium');
await denied('select * from public.sun_fetch_app_state($1)',[ws],/Access denied/);
await denied('select public.caterium_trial_demo_status($1)',[ws],/Access denied/);
await denied('select public.caterium_install_trial_demo($1)',[ws],/Administrator membership/);
await denied('select public.caterium_install_trial_banquet($1)',[ws],/Administrator membership/);
await denied('select public.caterium_install_trial_extras($1)',[ws],/Administrator membership/);
await denied('select * from public.sun_v17_clients_snapshot_v1773($1)',[ws],/Access denied/);
await denied('select public.sun_dev_dashboard()',[],/administrator/);
await actor(ids[3]);
@ -112,6 +134,7 @@ await actor(ids[1]);
assert.equal((await one('select public.caterium_trial_demo_status($1) as s',[ws])).s.eligible,false,'Solnce company excluded');
await denied('select public.caterium_install_trial_demo($1)',[ws],/Демонстрационная база доступна/);
await denied('select public.caterium_install_trial_banquet($1)',[ws],/Демонстрационная база доступна/);
await denied('select public.caterium_install_trial_extras($1)',[ws],/Демонстрационная база доступна/);
await db.exec('reset role');
const devWs=(await one("insert into public.sun_workspaces(name,created_by) values('Own developer test',$1) returning id",[ids[0]])).id;
await q("insert into public.sun_workspace_members(workspace_id,user_id,role,is_active,permissions) values($1,$2,'admin',true,public.sun_role_default_permissions('admin'))",[devWs,ids[0]]);
@ -122,7 +145,8 @@ await actor(ids[0]);
await q('select public.caterium_install_trial_demo($1)',[devWs]);
const seeded=(await one('select * from public.sun_fetch_app_state($1)',[devWs])).payload.storage;
assert.deepEqual(seeded.sunClientLoyaltyV1,preserved.storage.sunClientLoyaltyV1);assert.deepEqual(seeded.sunCompanyProfileV1,preserved.storage.sunCompanyProfileV1);
assert.equal(seeded.sunBoxes.v.length,28,'developer own company works without developer-panel MFA');
assert.equal(seeded.sunBoxes.v.length,44,'developer own company works without developer-panel MFA');
await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_extras($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_extras_seed_v1()',[],/permission denied/);
await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_banquet($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_banquet_seed_v1()',[],/permission denied/);
await db.exec('reset role; set role anon');await denied('select public.caterium_install_trial_demo($1)',[devWs],/permission denied/);await denied('select public.caterium_trial_demo_seed_v1()',[],/permission denied/);
console.log('PASS demo: trial auto seed, 10 box + 18 banquet TTKs, stock links, additive upgrade preserving orders/prices/balances, idempotency, populated catalog guard, Solnce exclusion, own developer account and preserved clients');

View File

@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent')
check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer');
check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox');
check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4');
check(sw.includes('v97-20260918-trial-banquet')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260918-trial-banquet')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(sw.includes('v98-20260918-trial-extras')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js')&&sw.includes('offer-workspace-v1769.js'),'service worker cache is v17.7.3');
check(index.includes('20260918-trial-extras')&&index.includes('classic-offer-pdf-v1767.js')&&!index.includes('20260907-v17-6-0-stability-security'),'index cache-busting points to v17.7.3');
check(performance.includes('SunAttachmentGuard')&&performance.includes('TARGET=2*1024*1024'),'chat photo auto-compression is versioned');
check(performance.includes("rpc('sun_dev_dashboard')")&&performance.includes('server_size')&&performance.includes('storage_size'),'Developer Console server/storage counters are versioned');
check(performance.includes('MEMORY_REFRESH_MS=30000')&&performance.includes('MEMORY_TIMEOUT_MS=8000')&&performance.includes('memoryPromise'),'Developer Console memory refresh is bounded');
@ -39,7 +39,7 @@ check(!/sb_secret_[A-Za-z0-9_-]{20,}|service_role\s*[:=]\s*["'][A-Za-z0-9._-]{30
check(lock.version===pkg.version&&lock.packages?.['']?.version===pkg.version,'package.json and package-lock.json versions match');
check(releaseManifest.version===`v${pkg.version}`,'release manifest version matches package.json');
check(releaseManifest.channel==='production','release manifest channel is production');
check(String(releaseManifest.pwaCache||'').includes('v97-20260918-trial-banquet'),'release manifest points to current PWA cache');
check(String(releaseManifest.pwaCache||'').includes('v98-20260918-trial-extras'),'release manifest points to current PWA cache');
check(['17.6.2','17.6.3','17.6.4','17.6.5','17.6.6','17.6.7','17.6.8','17.6.9','17.7.0','17.7.1','17.7.2','17.7.3'].every(v=>fs.existsSync(path.join(root,`docs/releases/V${v}-CHANGES.txt`))),'release notes exist through v17.7.3');
check(runtime.includes('CLOUD_RPC_TIMEOUT_MS=45000')&&runtime.includes('CLOUD_CONFLICT_MAX_RETRIES=4')&&runtime.includes('retryCount'),'cloud sync has timeout and capped exponential conflict retries');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes('ERROR_DEDUPE_MS=5*60*1000')&&runtime.includes('mirrorBusy=false')&&runtime.includes('backupBusy=false'),'stability logger uses current version, dedupe and single-flight guards');
@ -65,8 +65,8 @@ check(offerWorkspace.includes('PDF и предпросмотр')&&offerWorkspace
check(offerWorkspace.includes('SunClassicOfferPDFV1767')&&offerWorkspace.includes('finalGallery=galleryFor'),'custom gallery is injected into PDF renderer');
check(releaseManifest.offerWorkspaceTabs===true&&releaseManifest.offerTemplatesSeparateTab===true&&releaseManifest.offerTwoCustomGalleryPhotos===true,'release manifest records offer workspace changes');
check(pkg.version==='17.7.3','package version is v17.7.3');
check(index.includes('20260918-trial-banquet'),'index cache bust is v17.7.3');
check(sw.includes('v97-20260918-trial-banquet')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(index.includes('20260918-trial-extras'),'index cache bust is v17.7.3');
check(sw.includes('v98-20260918-trial-extras')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules');
check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist');
check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active');
check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3');

View File

@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el
if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos');
const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x));
if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed');
if(!sw.includes('20260918-trial-banquet')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-trial-banquet')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!sw.includes('20260918-trial-extras')||!sw.includes('login-signature-v1776.js')||!sw.includes('data-layer-v1773.js')||!sw.includes('server-automation-v1770.js')||!sw.includes('offer-workspace-v1769.js')||sw.includes('offer-gallery-data.js'))fail('service worker cache is stale');else ok('PWA cache updated for login refresh');
if(html.includes('20260907-v17-6-0-stability-security')||html.includes('20260909-v17-7-3-clients-server-read')||!html.includes('20260918-trial-extras')||!html.includes('classic-offer-pdf-v1767.js'))fail('index still serves stale core asset version');else ok('index cache-busting is current');
if(!performance.includes('SunAttachmentGuard')||!performance.includes('MAX_SIDE=2048'))fail('chat photo compression guard missing');else ok('chat photo compression guard present');
if(!performance.includes("rpc('sun_dev_dashboard')")||!performance.includes('storage_size')||!performance.includes('server_size'))fail('Developer Console memory counters missing');else ok('Developer Console memory counters present');
if(performance.includes('records.forEach(r=>r.addedNodes.forEach(n=>{if(n.nodeType===1)scan(n)}));enhanceDeveloperMemory()'))fail('Developer Console memory refresh is still coupled to MutationObserver');else ok('Developer Console memory refresh loop removed');

View File

@ -2,7 +2,8 @@ import {test,expect} from '@playwright/test';
import fs from 'node:fs';
import {demo} from '../ops/demo/trial-demo-data.mjs';
import {banquetDemo} from '../ops/demo/trial-banquet-data.mjs';
const completeDemo={...demo,boxes:[...demo.boxes,...banquetDemo.banquet],stock:[...demo.stock,...banquetDemo.stock]};
import {extrasDemo} from '../ops/demo/trial-extras-data.mjs';
const completeDemo={...demo,boxes:[...demo.boxes,...banquetDemo.banquet,...extrasDemo.items],stock:[...demo.stock,...banquetDemo.stock,...extrasDemo.stock]};
test('installed app serves old trial PNG links as cached lightweight images even offline',async({page,context})=>{
await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort());
@ -87,6 +88,32 @@ test('trial banquet categories, per-guest TTK, shopping and stock work together'
expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-banquet-v1-stock-potato').qty)).toBeCloseTo(.32,3);
});
test('trial extras populate every tab and supplies reach shopping and stock',async({page})=>{
await ready(page);
for(const [label,count] of [['Премиум',3],['Напитки',4],['Посуда',4],['Дополнения',3],['Доставка',2]]){
await page.getByRole('button',{name:label,exact:true}).click();
await expect(page.locator('#tiles .tile:not(.add)')).toHaveCount(count);
}
await page.getByRole('button',{name:'Напитки',exact:true}).click();
await page.locator('#tiles .tile').filter({hasText:'Вода негазированная'}).click();
await page.locator('#tiles .tile').filter({hasText:'Вода негазированная'}).click();
await page.getByRole('button',{name:'Посуда',exact:true}).click();
await page.locator('#tiles .tile').filter({hasText:'Тарелка одноразовая'}).click();
await page.getByRole('button',{name:'Доставка',exact:true}).click();
await page.locator('#tiles .tile').filter({hasText:'Доставка по городу'}).click();
expect(await page.evaluate(()=>window.sunBaseOrderTotal(draft))).toBe(805);
await page.evaluate(()=>{const day=new Date();day.setDate(day.getDate()+1);draft.event='Демо с напитками';draft.date=`${day.getFullYear()}-${String(day.getMonth()+1).padStart(2,'0')}-${String(day.getDate()).padStart(2,'0')}`;draft.time='12:00';draft.autoCompletionDisabled=true;document.getElementById('date').value=draft.date;document.getElementById('time').value=draft.time;window.saveOrder()});
await page.getByRole('button',{name:'Закупки',exact:true}).first().click();
await expect(page.locator('[data-shop-key="демо: вода негазированная 0,5 л|шт."]')).toContainText('2');
await expect(page.locator('[data-shop-key="демо: тарелка одноразовая|шт."]')).toContainText('1');
expect((await page.locator('[data-shop-key]').allTextContents()).every(text=>!text.includes('Доставка по городу'))).toBe(true);
await page.getByRole('button',{name:'Склад',exact:true}).first().click();
await page.evaluate(()=>window.stockMovement('demo-extras-v1-stock-water','in'));
await page.locator('#stockMoveQty').fill('5');await page.locator('#stockSaveMove').click();
await page.evaluate(()=>window.writeOffOrderStock(orders.at(-1).id));
expect(await page.evaluate(()=>JSON.parse(localStorage.sunStock).find(p=>p.id==='demo-extras-v1-stock-water').qty)).toBe(3);
});
test('owner onboarding preserves server demo when saving the company profile',async({page})=>{
await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'<!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'});