From 2d49563b9d7b8d45c40a977cc8bc33655f609851 Mon Sep 17 00:00:00 2001 From: pavlov346346-source Date: Thu, 17 Sep 2026 21:07:54 +0300 Subject: [PATCH] Add banquet packages, dish sections and guest totals --- docs/BANQUET-MENU-20260917.md | 9 +++++ docs/release-manifest.json | 2 +- package.json | 2 +- public/app-runtime.js | 34 +++++++----------- public/core/banquet-menu.js | 67 +++++++++++++++++++++++++++++++++++ public/core/performance.js | 2 +- public/index.html | 6 ++-- public/service-worker.js | 8 ++--- tests/banquet-menu.spec.mjs | 44 +++++++++++++++++++++++ tests/playwright.config.mjs | 2 +- tests/release-check.mjs | 10 +++--- tests/static-security.mjs | 4 +-- 12 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 docs/BANQUET-MENU-20260917.md create mode 100644 public/core/banquet-menu.js create mode 100644 tests/banquet-menu.spec.mjs diff --git a/docs/BANQUET-MENU-20260917.md b/docs/BANQUET-MENU-20260917.md new file mode 100644 index 0000000..3022d7a --- /dev/null +++ b/docs/BANQUET-MENU-20260917.md @@ -0,0 +1,9 @@ +# Banquet menu + +Category 6 now has package filters and dish sections, with an independent selection summary. Filters never remove selected dishes from totals. Alternative main courses are mutually exclusive within a package. Quantities and totals scale by the guest count, and applying a selection replaces only banquet lines in the current order. Existing order prices remain intact when a catalog price changes. + +Catalog entries carry optional `banquet` metadata (package identifier/name, original package price/grams, main-course choice group and estimate flag). All menu data belongs to the workspace. The public code contains no company menu seed; new companies remain empty. Package names, sections, weight, price and estimate flag are editable in the existing dish editor. + +The first company import uses the user-provided image. The per-dish weights and prices are explicitly estimates allocated from each complete menu. One alternative main is counted per guest; every alternative reproduces its package's advertised weight and price. The source's mushrooms and eggplant in the luxury menu are represented as separate dishes. The importer is restricted to the authorized company, creates a backup, appends unique IDs, preserves unrelated catalog entries/orders/customer state, and checks normalized catalog rows and totals. + +Banquet dish weights are included in order documents and client-offer food totals. Tests cover selection across filters, changing the main, guest scaling, repeated application without duplicate lines, safe rendering, editing estimates and saving the resulting order. diff --git a/docs/release-manifest.json b/docs/release-manifest.json index 0b880de..8e69141 100644 --- a/docs/release-manifest.json +++ b/docs/release-manifest.json @@ -11,7 +11,7 @@ "serverReady": true, "workspaceAutoDiscovery": true, "invitesTemporarilyDisabled": false, - "pwaCache": "v87-20260917-account-access", + "pwaCache": "v88-20260917-banquet-menu", "fullOfferDescriptions": true, "dynamicOfferRows": true, "pdfOfferDescriptionFix": true, diff --git a/package.json b/package.json index 5468fac..1b13855 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "17.7.3", "type": "module", "scripts": { - "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js", + "check:syntax": "node --check public/app-runtime.js && node --check public/service-worker.js && node --check public/legacy/bootstrap.js && node --check public/core/sun-safe.js && node --check public/core/account-center-v1780.js && node --check public/core/performance.js && node --check public/core/auth-security-v1774.js && node --check public/core/trial-promo-developer-v181.js && node --check public/core/order-enhancements-v1775.js && node --check public/core/data-layer-v1773.js && node --check public/core/server-automation-v1770.js && node --check public/core/hotfix-v1763.js && node --check public/core/ops-ux-v1762.js && node --check public/core/ux-fixes-v1764.js && node --check public/core/pdf-engine.js && node --check public/core/classic-offer-pdf-v1767.js && node --check public/core/signature-offer-pdf-v18.js && node --check public/core/developer-console-v1768.js && node --check public/core/offer-workspace-v1769.js && node --check public/core/brand-theme.js && node --check public/core/company-branding.js && node --check public/core/import-archive.js && node --check public/core/access-policy.js && node --check public/core/banquet-menu.js", "test:static": "node tests/static-security.mjs && node tests/auth-security-v1774.mjs && node tests/employee-create-v1774.mjs && node tests/html-integrity-v1774.mjs && node tests/edge-security-v1774.mjs && node tests/branding-v1774.mjs && node tests/order-enhancements-v1775.mjs", "check:release": "node tests/release-check.mjs", "check:deploy": "npm run check:syntax && npm run test:static && npm run check:release && node tests/backend-cutover.mjs && npm run test:db", diff --git a/public/app-runtime.js b/public/app-runtime.js index 145e103..4383e9f 100644 --- a/public/app-runtime.js +++ b/public/app-runtime.js @@ -421,6 +421,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const fields=ensureBanquetEditor();if(!fields)return;const isBanquet=Number(category)===BANQUET_CATEGORY; fields.section.style.display=isBanquet?'flex':'none';fields.weight.style.display=isBanquet?'flex':'none'; const caption=$('boxPriceCaption');if(caption)caption.textContent=isBanquet?'Цена на 1 гостя, ₽':'Цена, ₽'; + window.CateriumBanquet?.editor(category,item,boxes.filter(i=>Number(i.category)===6)); if(isBanquet){if($('banquetSection'))$('banquetSection').value=item?.catalogSection||'';if($('banquetWeightGrams'))$('banquetWeightGrams').value=parseWeightGrams(item?.weight)||''} } function installIngredientEditorV175(){window.renderIngredients=()=>{if(!edited)return;normalizeBoxItem(edited);const root=$('ingredients');if(!root)return;const head='
Вид / названиеКол-воЕд.Вес 1 шт., г
';root.innerHTML=head+edited.ingredients.map((row,i)=>{const w=ingredientUnitWeight(row);return `
`}).join('')};window.addIngredient=()=>{if(!edited)return;if(!Array.isArray(edited.ingredients))edited.ingredients=[];edited.ingredients.push(['Новая позиция',1,'шт.',0]);window.renderIngredients();};} @@ -428,7 +429,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; function renderOrderLines(){ if(!$('lines'))return; if(!(draft.lines||[]).length){$('lines').innerHTML='

Выберите позиции из каталога слева.

';return} - const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5].includes(Number(item.category||0));return `
${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`Количество: ${inferBoxPieces(item)} шт.`:''}`:''}
${money(price*qty)}
`}).join(''); + const rows=draft.lines.map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id));if(!item)return'';const qty=Math.max(1,Number(line.qty||1)),price=lineUnitPrice(line),isBox=[0,5,6].includes(Number(item.category||0));return `
${esc(String(item.name||'').replace(/^(?:Фуршетный\s+)?бокс\s*№\s*(\d+)\s*(?:[—–-]\s*)?/i,'№ $1 '))}${isBox&&(item.weight||inferBoxPieces(item))?`${item.weight?`Вес: ${esc(item.weight)}`:''}${item.weight&&inferBoxPieces(item)?' · ':''}${inferBoxPieces(item)?`Количество: ${inferBoxPieces(item)} шт.`:''}`:''}
${money(price*qty)}
`}).join(''); $('lines').innerHTML=`
НаименованиеКол.ЦенаСтоимость
${rows}
Стоимость позиций${money(baseTotal(draft))}
`; } window.sunOrderLinePriceChanged=(index,input)=>{const line=draft.lines?.[index];if(!line)return;line.price=Math.max(0,Math.round(Number(input.value||0)));const row=input.closest('.sun-order-line-row'),sum=row?.querySelector('.sun-order-line-sum b');if(sum)sum.textContent=money(line.price*Math.max(0,Number(line.qty||0)));const total=$('lines')?.querySelector('.sun-order-line-total b');if(total)total.textContent=money(baseTotal(draft));updateOrderSummary();}; @@ -436,23 +437,14 @@ window.SUN_LEGACY_CATALOG_V175=[]; function wantedBoxNumber(query){const raw=String(query||'').trim();if(!raw)return null;const match=raw.match(/^(?:бокс\s*)?(?:№\s*)?0*(\d+)\s*$/i);return match?Number(match[1]):null} function itemMatches(item,query){if(!query)return true;if(Number(activeCat)===0){const wanted=wantedBoxNumber(query);return wanted!==null&&boxNumber(item)===wanted}const q=query.trim().toLowerCase();const parts=[item.name,item.catalogSection,item.weight,...(Array.isArray(item.composition)?item.composition:[]),...(Array.isArray(item.ingredients)?item.ingredients.map(x=>x?.[0]):[])];return parts.join(' ').toLowerCase().includes(q)} const BANQUET_CATEGORY=6; - function banquetSelection(){if(!Array.isArray(draft.banquetSelection))draft.banquetSelection=[];return draft.banquetSelection} - window.toggleBanquetItem=id=>{const sel=banquetSelection();const i=sel.indexOf(String(id));if(i>=0)sel.splice(i,1);else sel.push(String(id));renderCatalogV5();}; - function renderBanquetView(items){ - const sel=new Set(banquetSelection().map(String)); - const groups=new Map(); - items.forEach(item=>{const g=String(item.catalogSection||'Без раздела').trim()||'Без раздела';if(!groups.has(g))groups.set(g,[]);groups.get(g).push(item)}); - const groupsHtml=[...groups.entries()].map(([section,list])=>`

${esc(section)}

${list.map(item=>``).join('')}
`).join(''); - const selectedItems=items.filter(item=>sel.has(String(item.id))); - const total=selectedItems.reduce((s,i)=>s+Number(i.price||0),0); - const summaryRows=selectedItems.map(item=>`
${esc(item.name)}${money(item.price||0)}
`).join(''); - const summaryHtml=`

Меню на 1 гостя

${selectedItems.length?summaryRows:'

Отметьте блюда слева.

'}
Итого на 1 гостя${money(total)}
`; - return `
${groups.size?groupsHtml:'
В разделе «Банкетное меню» пока нет позиций.
'}
${summaryHtml}
`; - } + function banquetCatalog(){return boxes.filter(item=>Number(item.category)===BANQUET_CATEGORY)} + window.toggleBanquetItem=id=>{window.CateriumBanquet.toggle(draft,banquetCatalog(),String(id));renderCatalogV5();}; + function renderBanquetView(items){return window.CateriumBanquet.render({items,catalog:banquetCatalog(),draft})} function renderCatalogV5(){ + $('new')?.classList.toggle('sun-banquet-active',Number(activeCat)===BANQUET_CATEGORY); renderCategoryTabs();ensureCatalogTools();const catalogSearch=$('sunCatalogSearch');if(catalogSearch){const boxMode=[0,5].includes(Number(activeCat));catalogSearch.placeholder=boxMode?'Поиск по № бокса':'Поиск по позиции';catalogSearch.inputMode=boxMode?'numeric':'search';}const title=document.querySelector('#new .catalog h1');if(title)title.textContent=catName(activeCat); const all=boxes.filter(item=>Number(item.category||0)===Number(activeCat)),items=all.filter(item=>itemMatches(item,catalogQuery));const cat=catById(activeCat)||{name:'Каталог',prep:true}; - if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);renderOrderLines();updateOrderSummary();return;} + if(Number(activeCat)===BANQUET_CATEGORY){$('tiles').innerHTML=renderBanquetView(items);window.CateriumBanquet.bind($('tiles'),{catalog:banquetCatalog(),draft,rerender:renderCatalogV5});renderOrderLines();updateOrderSummary();return;} const cards=items.map(item=>``).join(''); const addText=Number(activeCat)===0?'Добавить бокс':(Number(activeCat)===5?'Добавить премиум':'Добавить позицию'); const empty=items.length?'':`
${catalogQuery?'По вашему запросу ничего не найдено.':`В разделе «${esc(cat.name)}» пока нет позиций.`}
`; @@ -479,7 +471,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; const cat=catById(edited.category)||{name:'Каталог',prep:true};$('editTitle').textContent=edited.id?`Изменить: ${cat.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=cat.prep?'block':'none';window.renderIngredients?.();window.modal?.('editor'); }; if($('itemCategory'))$('itemCategory').onchange=function(){if(!edited)return;edited.category=Number(this.value);const c=catById(edited.category)||{name:'Каталог',prep:true};const zone=document.querySelector('#editor .ingredient-zone');if(zone)zone.style.display=c.prep?'block':'none';syncBoxWeightEditor(edited.category,edited);syncBanquetEditor(edited.category,edited);$('editTitle').textContent=edited.id?`Изменить: ${c.name}`:(Number(edited.category)===0?'Новый бокс':(Number(edited.category)===5?'Новая премиум позиция':'Новая позиция'));}; - window.saveBox=()=>{if(!edited)return;edited.name=$('boxName').value.trim();edited.price=Math.max(0,Number($('boxPrice').value||0));edited.oldPrice=Math.max(0,Number($('boxOldPrice')?.value||0));if(!(edited.oldPrice>edited.price))delete edited.oldPrice;edited.sale=Boolean(edited.oldPrice&&edited.oldPrice>edited.price);edited.category=Number($('itemCategory').value);if([0,5].includes(edited.category)){const grams=Math.max(0,Math.round(Number($('boxWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';edited.pieces=Math.max(0,Math.round(Number($('boxPiecesCount')?.value||0)));}if(edited.category===BANQUET_CATEGORY){edited.catalogSection=($('banquetSection')?.value||'').trim();const grams=Math.max(0,Math.round(Number($('banquetWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';}edited.ingredients=Array.isArray(edited.ingredients)?edited.ingredients:[];if([0,5].includes(edited.category)&&$('boxCompositionText')){const next=$('boxCompositionText').value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);const before=JSON.stringify(Array.isArray(edited.composition)?edited.composition:[]);edited.composition=next;if(JSON.stringify(next)!==before)edited.compositionSource='manual';}normalizeBoxItem(edited,{cleanup:true});if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=(typeof sunUUID==='function'?sunUUID():`item-${Date.now()}`);boxes.push(edited)}else{const i=boxes.findIndex(x=>String(x.id)===String(edited.id));if(i>=0)boxes[i]=edited}if(!catById(edited.category)?.hidden)activeCat=edited.category;persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();window.sunClientOfferCatalogChanged?.(edited.id);}; + window.saveBox=()=>{if(!edited)return;edited.name=$('boxName').value.trim();edited.price=Math.max(0,Number($('boxPrice').value||0));edited.oldPrice=Math.max(0,Number($('boxOldPrice')?.value||0));if(!(edited.oldPrice>edited.price))delete edited.oldPrice;edited.sale=Boolean(edited.oldPrice&&edited.oldPrice>edited.price);edited.category=Number($('itemCategory').value);if([0,5].includes(edited.category)){const grams=Math.max(0,Math.round(Number($('boxWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';edited.pieces=Math.max(0,Math.round(Number($('boxPiecesCount')?.value||0)));}if(edited.category===BANQUET_CATEGORY){edited.catalogSection=($('banquetSection')?.value||'').trim();const grams=Math.max(0,Math.round(Number($('banquetWeightGrams')?.value||0)));edited.weight=grams?`${grams} г`:'';}window.CateriumBanquet?.saveEditor(edited,boxes.filter(i=>Number(i.category)===6));edited.ingredients=Array.isArray(edited.ingredients)?edited.ingredients:[];if([0,5].includes(edited.category)&&$('boxCompositionText')){const next=$('boxCompositionText').value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);const before=JSON.stringify(Array.isArray(edited.composition)?edited.composition:[]);edited.composition=next;if(JSON.stringify(next)!==before)edited.compositionSource='manual';}normalizeBoxItem(edited,{cleanup:true});if(!edited.name)return alert('Введите название.');if(!edited.id){edited.id=(typeof sunUUID==='function'?sunUUID():`item-${Date.now()}`);boxes.push(edited)}else{const i=boxes.findIndex(x=>String(x.id)===String(edited.id));if(i>=0)boxes[i]=edited}if(!catById(edited.category)?.hidden)activeCat=edited.category;persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();window.sunClientOfferCatalogChanged?.(edited.id);}; window.removeBox=()=>{if(!edited?.id||!confirm(`Удалить «${edited.name}» из каталога?`))return;boxes=boxes.filter(x=>String(x.id)!==String(edited.id));draft.lines=(draft.lines||[]).filter(x=>String(x.id)!==String(edited.id));persist();closeModal('editor');closeModal('manager');renderCatalogV5();renderOrders?.();}; function fileAsDataUrl(file){return new Promise((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(reader.error||new Error('FileReader error'));reader.onload=()=>resolve(String(reader.result||''));reader.readAsDataURL(file)})} @@ -662,16 +654,16 @@ window.SUN_LEGACY_CATALOG_V175=[]; function party(title,rows){const list=rows.filter(([,v])=>String(v||'').trim());if(!list.length)return'';return `

${esc(title)}

${list.map(([k,v])=>`
${esc(k)}${esc(v)}
`).join('')}
`} // ---------------- Order blank + compact receipt ---------------- - function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)} + function currentRows(){return (draft.lines||[]).map((line,index)=>{const item=boxes.find(x=>String(x.id)===String(line.id)),price=lineUnitPrice(line),qty=Math.max(0,Number(line.qty||0)),categoryId=Number(item?.category??line.category??0);return{index:index+1,name:item?.name||line.name||`Позиция ${line.id||''}`,category:catName(categoryId),categoryId,weight:[0,5,6].includes(categoryId)?String(item?.weight||''):'',pieces:[0,5].includes(categoryId)?inferBoxPieces(item):0,price,qty,sum:price*qty}}).filter(r=>r.qty>0)} const docDate=raw=>{if(!raw)return'—';const d=new Date(`${raw}T00:00:00`);return Number.isNaN(d.getTime())?raw:d.toLocaleDateString('ru-RU')}; const docActions=()=>`
`; function showOrderBlank(){ captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),qr=defaultQr(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const number=draft.id||'черновик',qrNote=String(draft.blankQrText||'').trim(); - dialog.innerHTML=`
${window.CateriumBranding.nameHTML()}
${window.CateriumBranding.nameHTML()}${window.CateriumBranding.cityHTML()}

Заказ № ${esc(number)}

${esc($('event')?.value||'Новое мероприятие')}

${draft.status?`${esc(draft.status)}`:''}
${qr?`
QR-код
${esc(qrNote)}
`:''}
Клиент${esc($('contact')?.value||'—')}
Телефон${esc($('phone')?.value||'—')}
Дата и время выдачи${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}
Адрес доставки${esc($('address')?.value||'—')}
${b.name?`
Компания покупателя${esc(b.name)}
`:''}
${rows.length?rows.map(r=>``).join(''):''}
ПозицияЦенаКол.Сумма
${r.index}${esc(r.name)}${esc(r.category)}${(r.weight||r.pieces)?`${r.weight?`Вес бокса: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе пока нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Предоплата${money(p.paid)}

Остаток к оплате${money(p.balance)}

${$('note')?.value?`
Комментарий к заказу${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);} + dialog.innerHTML=`
${window.CateriumBranding.nameHTML()}
${window.CateriumBranding.nameHTML()}${window.CateriumBranding.cityHTML()}

Заказ № ${esc(number)}

${esc($('event')?.value||'Новое мероприятие')}

${draft.status?`${esc(draft.status)}`:''}
${qr?`
QR-код
${esc(qrNote)}
`:''}
Клиент${esc($('contact')?.value||'—')}
Телефон${esc($('phone')?.value||'—')}
Дата и время выдачи${esc(docDate($('date')?.value))} · ${esc($('time')?.value||'—')}
Адрес доставки${esc($('address')?.value||'—')}
${b.name?`
Компания покупателя${esc(b.name)}
`:''}
${rows.length?rows.map(r=>``).join(''):''}
ПозицияЦенаКол.Сумма
${r.index}${esc(r.name)}${esc(r.category)}${(r.weight||r.pieces)?`${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе пока нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Предоплата${money(p.paid)}

Остаток к оплате${money(p.balance)}

${$('note')?.value?`
Комментарий к заказу${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview');const noteEl=$('sunBlankQrText');if(noteEl){const save=()=>{const text=String(noteEl.innerText||noteEl.textContent||'').replace(/\u00a0/g,' ').trim();draft.blankQrText=text;if(draft.id){const saved=(orders||[]).find(o=>String(o.id)===String(draft.id));if(saved){saved.blankQrText=text;try{persist()}catch(_){}}}};noteEl.addEventListener('input',save);noteEl.addEventListener('blur',save);} } function showReceipt(){ captureBuyerFromFields();captureDiscounts();const rows=currentRows(),p=pricing(draft),s=readSeller(),b=draft.buyerRequisites||buyerDefaults(),dialog=document.querySelector('#preview .dialog');if(!dialog)return;const seller=s.includeSeller!==false?party('Продавец',[['Наименование',s.sellerLegalName||s.sellerName],['ИНН',s.sellerInn],['КПП',s.sellerKpp],['ОГРН / ОГРНИП',s.sellerOgrn],['Адрес',s.sellerLegalAddress],['Телефон',s.sellerPhone],['E-mail',s.sellerEmail],['Банк',s.sellerBank],['БИК',s.sellerBik],['Расч. счёт',s.sellerAccount],['Корр. счёт',s.sellerCorrAccount]]):'';const buyer=party('Покупатель',[['Компания / ИП',b.name],['ИНН',b.inn],['КПП',b.kpp],['ОГРН / ОГРНИП',b.ogrn],['Адрес',b.legalAddress],['Контакт',b.contact],['Телефон',b.phone],['E-mail',b.email],['Банк',b.bank],['БИК',b.bik],['Расч. счёт',b.account],['Корр. счёт',b.corrAccount]]); - dialog.innerHTML=`
${window.CateriumBranding.nameHTML()}
${esc(s.sellerName||(window.CateriumBranding.documentName()))}${esc(s.sellerLegalName||'')}

Товарный чек № ${esc(draft.id||'—')}

${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}

${seller}${buyer}${rows.length?rows.map(r=>``).join(''):''}
НаименованиеЦенаКол.Сумма
${r.index}${esc(r.name)}${(r.weight||r.pieces)?`${r.weight?`Вес бокса: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Оплачено${money(p.paid)}

К оплате${money(p.balance)}

${$('note')?.value?`
Примечание${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview'); + dialog.innerHTML=`
${window.CateriumBranding.nameHTML()}
${esc(s.sellerName||(window.CateriumBranding.documentName()))}${esc(s.sellerLegalName||'')}

Товарный чек № ${esc(draft.id||'—')}

${esc(docDate($('date')?.value||draft.date))} · ${esc($('time')?.value||draft.time||'')}

${seller}${buyer}${rows.length?rows.map(r=>``).join(''):''}
НаименованиеЦенаКол.Сумма
${r.index}${esc(r.name)}${(r.weight||r.pieces)?`${r.weight?`${r.categoryId===6?'Вес порции':'Вес бокса'}: ${esc(r.weight)}`:''}${r.weight&&r.pieces?' · ':''}${r.pieces?`Количество: ${esc(r.pieces)} шт.`:''}`:''}${money(r.price)}${r.qty}${money(r.sum)}
В заказе нет позиций.

Стоимость позиций${money(p.base)}

${p.manual?`

Скидка− ${money(p.manual)}

`:''}${p.promo?`

Промокод ${esc(draft.promoCode||'')}− ${money(p.promo)}

`:''}

Доставка${money(p.delivery)}

Итого${money(p.total)}

Оплачено${money(p.paid)}

К оплате${money(p.balance)}

${$('note')?.value?`
Примечание${esc($('note').value)}
`:''}${docActions()}
`;window.modal?.('preview'); } window.sunShowConfiguredReceipt=showReceipt;window.sunShowOrderBlankV5=showOrderBlank; function patchDocButtons(){qa('#new .actions button').forEach(b=>{const text=(b.textContent||'').trim();if(text==='Товарный чек')b.onclick=showReceipt;if(text==='Бланк заказа')b.onclick=showOrderBlank;});} @@ -1047,7 +1039,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; let foodGrams=0,drinkMl=0,foodPieces=0; const items=(order?.lines||[]).map(line=>{ const item=resolveCatalogLine(line,all)||{};const qty=num(line.qty),price=linePrice(line,item),measure=parseMeasure(item.weight),rawItemCategory=item.category,rawLineCategory=line.category,itemCategory=rawItemCategory!==undefined&&rawItemCategory!==null&&rawItemCategory!==''?Number(rawItemCategory):NaN,lineCategory=rawLineCategory!==undefined&&rawLineCategory!==null&&rawLineCategory!==''?Number(rawLineCategory):NaN,categoryId=Number.isFinite(itemCategory)?itemCategory:(Number.isFinite(lineCategory)?lineCategory:-1),pieces=canapePieces(item),comp=composition(item),compositionTotal=[0,5].includes(Number(categoryId))?comp.map(x=>multiplyLeadingPieces(x,qty)):comp; - if(measure){if(measure.kind==='drink'||categoryId===3)drinkMl+=measure.value*qty;else if([0,5].includes(categoryId))foodGrams+=measure.value*qty;} + if(measure){if(measure.kind==='drink'||categoryId===3)drinkMl+=measure.value*qty;else if([0,5,6].includes(categoryId))foodGrams+=measure.value*qty;} if([0,5].includes(categoryId)&&pieces>0)foodPieces+=pieces*qty; return {id:String(item.id??line.id??''),name:String(item.name||line.name||'Позиция'),categoryId,categoryName:catName(categoryId),catalogSection:String(item.catalogSection||''),qty,unitPrice:price,sum:price*qty,weight:String(item.weight||''),boxGuests:String(item.guests||''),photo:String(item.photo||''),composition:comp,compositionTotal,pieces}; }).filter(x=>x.qty>0); @@ -1142,7 +1134,7 @@ window.SUN_LEGACY_CATALOG_V175=[]; item.photoData=item.photo||copy.logo; } copy.foodGrams=0;copy.drinkMl=0;copy.foodPieces=0; - for(const item of copy.items){const measure=parseMeasure(item.weight);if(measure){if(measure.kind==='drink'||Number(item.categoryId)===3)copy.drinkMl+=measure.value*num(item.qty);else if([0,5].includes(Number(item.categoryId)))copy.foodGrams+=measure.value*num(item.qty);}if([0,5].includes(Number(item.categoryId))&&num(item.pieces)>0)copy.foodPieces+=num(item.pieces)*num(item.qty)} + for(const item of copy.items){const measure=parseMeasure(item.weight);if(measure){if(measure.kind==='drink'||Number(item.categoryId)===3)copy.drinkMl+=measure.value*num(item.qty);else if([0,5,6].includes(Number(item.categoryId)))copy.foodGrams+=measure.value*num(item.qty);}if([0,5].includes(Number(item.categoryId))&&num(item.pieces)>0)copy.foodPieces+=num(item.pieces)*num(item.qty)} try{const cfg=offerSettingsForSnapshot(copy);copy.controlLines=Array.isArray(cfg.controlLines)&&cfg.controlLines.length?cfg.controlLines:DELIVERY_CONTROL;copy.extraServices=Array.isArray(cfg.extraServices)&&cfg.extraServices.length?cfg.extraServices:EXTRA_SERVICES.map(x=>x[0]);copy.controlTitle=cfg.texts?.controlTitle||'';copy.extrasTitle=cfg.texts?.extrasTitle||'';}catch(_){} return copy; } diff --git a/public/core/banquet-menu.js b/public/core/banquet-menu.js new file mode 100644 index 0000000..24f886a --- /dev/null +++ b/public/core/banquet-menu.js @@ -0,0 +1,67 @@ +(()=>{ + 'use strict'; + const groups=[['cold','Холодные закуски'],['salads','Салаты'],['starters','Горячие закуски'],['main','Горячее'],['fruit','Фрукты и ягоды'],['bread','Хлеб и масло'],['other','Другие блюда']]; + let packageId='all',groupId='all'; + const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const money=v=>Math.round(Number(v)||0).toLocaleString('ru-RU')+' ₽'; + const grams=item=>{const value=String(item?.weight||'').replace(',','.');return Math.round((parseFloat(value)||0)*(/кг/i.test(value)?1000:1))}; + const group=item=>{const s=String(item?.catalogSection||'').toLowerCase();return /салат/.test(s)?'salads':/(горяч|тёпл|тепл).*закуск/.test(s)?'starters':/горяч|основн/.test(s)?'main':/фрукт|ягод/.test(s)?'fruit':/хлеб|масло|булоч/.test(s)?'bread':/рыб|мясн|сыр|овощ|холод|закуск/.test(s)?'cold':'other'}; + function packages(catalog){const result=new Map();for(const i of catalog){const b=i.banquet;if(b?.packageId&&!result.has(b.packageId))result.set(b.packageId,{id:b.packageId,name:b.packageName||b.packageId,price:b.packagePrice,grams:b.packageGrams})}return [...result.values()]} + function selection(draft,catalog){if(!Array.isArray(draft.banquetSelection)){const ids=new Set(catalog.map(i=>String(i.id)));draft.banquetSelection=(draft.lines||[]).filter(l=>ids.has(String(l.id))).map(l=>String(l.id))}return new Set(draft.banquetSelection.map(String))} + const guestCount=draft=>Math.max(1,Math.round(Number(draft.guestsCount)||1)); + const unitPrice=(item,draft)=>{const line=(draft.lines||[]).find(l=>String(l.id)===String(item.id));return line&&Number.isFinite(Number(line.price))?Math.max(0,Number(line.price)):Number(item.price)||0}; + function totals(items,draft){return {price:items.reduce((n,i)=>n+unitPrice(i,draft),0),grams:items.reduce((n,i)=>n+grams(i),0)}} + function chooseMenu(draft,catalog,id){const rows=catalog.filter(i=>i.banquet?.packageId===id),used=new Set();draft.banquetSelection=rows.filter(i=>{const choice=i.banquet?.choiceGroup;if(!choice)return true;if(used.has(choice))return false;const defaults=rows.some(r=>r.banquet?.choiceGroup===choice&&r.banquet.defaultChoice);if(defaults&&!i.banquet.defaultChoice)return false;used.add(choice);return true}).map(i=>String(i.id));packageId=id;groupId='all'} + function toggle(draft,catalog,id){const ids=selection(draft,catalog),item=catalog.find(i=>String(i.id)===id);if(!item)return;if(ids.has(id))ids.delete(id);else{const choice=item.banquet?.choiceGroup;if(choice)for(const old of catalog)if(old.banquet?.packageId===item.banquet.packageId&&old.banquet?.choiceGroup===choice)ids.delete(String(old.id));ids.add(id)}draft.banquetSelection=[...ids]} + function apply(draft,catalog){const selected=selection(draft,catalog),rows=catalog.filter(i=>selected.has(String(i.id))),ids=new Set(catalog.map(i=>String(i.id))),count=guestCount(draft);if(!rows.length)return;const lines=rows.map(i=>({id:i.id,name:i.name,category:6,qty:count,price:unitPrice(i,draft)}));draft.lines=[...(draft.lines||[]).filter(l=>!ids.has(String(l.id))&&Number(l.category)!==6),...lines];draft.guestsCount=count;return lines} + function render({items,catalog,draft}){ + const menus=packages(catalog);if(packageId!=='all'&&!menus.some(m=>m.id===packageId))packageId='all'; + const candidates=items.filter(i=>packageId==='all'||i.banquet?.packageId===packageId),available=groups.filter(([id])=>candidates.some(i=>group(i)===id)); + if(groupId!=='all'&&!available.some(([id])=>id===groupId))groupId='all'; + const visible=candidates.filter(i=>groupId==='all'||group(i)===groupId),ids=selection(draft,catalog),selected=catalog.filter(i=>ids.has(String(i.id))),sum=totals(selected,draft),count=guestCount(draft); + const menu=menus.find(m=>m.id===packageId),sections=new Map(); + for(const [id] of groups)for(const item of visible.filter(i=>group(i)===id)){const section=item.catalogSection||'Без раздела';if(!sections.has(section))sections.set(section,[]);sections.get(section).push(item)} + const estimates=catalog.some(i=>i.banquet?.estimated),hasExisting=(draft.lines||[]).some(l=>catalog.some(i=>String(i.id)===String(l.id))||Number(l.category)===6); + return `
+
Банкетное меню

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

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

+ ${menus.length?`
${menus.map(m=>``).join('')}
`:''} + ${estimates?'

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

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

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

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

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

`}
+
`; + } + function bind(root,context){ + const {catalog,draft,rerender}=context; + root.querySelectorAll('[data-banquet-package]').forEach(b=>b.onclick=()=>{packageId=b.dataset.banquetPackage;groupId='all';rerender()}); + root.querySelectorAll('[data-banquet-group]').forEach(b=>b.onclick=()=>{groupId=b.dataset.banquetGroup;rerender()}); + root.querySelectorAll('[data-banquet-item]').forEach(b=>b.onchange=()=>{toggle(draft,catalog,b.dataset.banquetItem);rerender()}); + root.querySelectorAll('[data-banquet-edit]').forEach(b=>b.onclick=()=>window.editBox?.(b.dataset.banquetEdit)); + const preset=root.querySelector('[data-banquet-preset]');if(preset)preset.onclick=()=>{chooseMenu(draft,catalog,preset.dataset.banquetPreset);rerender()}; + const clear=root.querySelector('[data-banquet-clear]');if(clear)clear.onclick=()=>{draft.banquetSelection=[];rerender()}; + root.querySelector('[data-banquet-add]').onclick=()=>window.editBox?.(null); + const guests=root.querySelector('[data-banquet-guests]');guests.oninput=()=>{draft.guestsCount=Math.min(10000,Math.max(1,Math.round(Number(guests.value)||1)));const field=document.getElementById('eventGuests');if(field)field.value=draft.guestsCount;const ids=selection(draft,catalog),sum=totals(catalog.filter(i=>ids.has(String(i.id))),draft);root.querySelector('[data-banquet-total]').textContent=money(sum.price*draft.guestsCount);root.querySelector('[data-banquet-total-weight]').textContent=(sum.grams*draft.guestsCount/1000).toLocaleString('ru-RU',{maximumFractionDigits:2})+' кг'};guests.onchange=()=>{guests.value=guestCount(draft)}; + root.querySelector('[data-banquet-apply]').onclick=()=>{if(window.SunAdminRBACV3&&!window.SunAdminRBACV3.hasPermission(draft.id?'orders.edit':'orders.create'))return;apply(draft,catalog);const field=document.getElementById('eventGuests');if(field)field.value=draft.guestsCount;rerender();window.SunEnterprise?.toast?.('Банкетное меню добавлено в заказ.','success')}; + } + function editor(category,item,catalog){ + const section=document.getElementById('sunBanquetSectionLabel');if(!section)return; + let extra=document.getElementById('ctBanquetEditor'); + if(!extra){extra=document.createElement('div');extra.id='ctBanquetEditor';extra.innerHTML='';section.insertAdjacentElement('afterend',extra)} + extra.hidden=Number(category)!==6;if(extra.hidden)return; + document.getElementById('ctBanquetPackage').value=item?.banquet?.packageName||''; + document.getElementById('ctBanquetEstimated').checked=item?.banquet?.estimated===true; + document.getElementById('ctBanquetPackageNames').innerHTML=packages(catalog).map(m=>``).join(''); + let list=document.getElementById('ctBanquetSections');if(!list){list=document.createElement('datalist');list.id='ctBanquetSections';list.innerHTML=['Рыбные закуски','Мясные закуски','Сыры','Овощные закуски','Салаты','Горячие закуски','Горячие блюда','Фрукты и ягоды','Хлеб и масло'].map(s=>``).join('');extra.appendChild(list)} + document.getElementById('banquetSection')?.setAttribute('list',list.id); + } + function saveEditor(item,catalog){if(Number(item.category)!==6)return;const input=document.getElementById('ctBanquetPackage');if(!input)return;const name=input.value.trim(),known=packages(catalog).find(m=>m.name===name),same=item.banquet?.packageName===name;item.banquet={...(same?item.banquet:{}),packageId:name?(known?.id||'custom-'+name.toLowerCase().replace(/\s+/g,'-')):'',packageName:name,estimated:document.getElementById('ctBanquetEstimated')?.checked===true};if(known){item.banquet.packagePrice=known.price;item.banquet.packageGrams=known.grams}} + const style=document.createElement('style');style.textContent=` + #tiles .ct-banquet{grid-column:1/-1;min-width:0;width:100%;color:#34352e}.ct-banquet-intro{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:6px 0 20px}.ct-eyebrow{font-size:10px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#8b775c}.ct-banquet h2{font-size:24px;margin:6px 0}.ct-banquet-intro p{font-size:12px;color:#7a7d75;margin:0}.ct-banquet-intro button{white-space:nowrap;font-size:12px}.ct-banquet-packages{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.ct-banquet-packages button{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:14px 12px;border:1px solid #e3e1d9;border-radius:12px;background:#fff;color:#3e4238;text-align:left;min-width:0}.ct-banquet-packages b{font-size:14px}.ct-banquet-packages span{font-size:20px;font-weight:700}.ct-banquet-packages small{font-size:11px;color:#777c70}.ct-banquet-packages button[aria-pressed=true]{background:#edf1e7;border:1.5px solid #667956;box-shadow:0 3px 12px #3344250a}.ct-banquet-estimate{font-size:11px;line-height:1.5;color:#81735f;margin:12px 0 18px}.ct-banquet-preset{display:flex;gap:10px;align-items:center;justify-content:space-between;background:#f6f4ed;border-radius:10px;padding:10px 12px;font-size:12px;margin:14px 0}.ct-banquet-preset button{font-size:11px;white-space:nowrap}.ct-banquet-groups{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.ct-banquet-groups button{border:1px solid #e5e4df;border-radius:20px;padding:8px 10px;background:white;color:#707467;font-size:11px;font-weight:600}.ct-banquet-groups button[aria-pressed=true]{background:#3c4933;border-color:#3c4933;color:white}.ct-banquet-groups span{opacity:.65;margin-left:4px}.ct-banquet-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start}.ct-banquet-dishes{min-width:0}.ct-banquet-section{margin-bottom:20px}.ct-banquet-section h3{font-size:12px;color:#7b806e;font-weight:700;letter-spacing:.025em;margin:0 0 8px;padding-top:8px}.ct-banquet-section h3 small{display:block;font-size:10px;font-weight:400;letter-spacing:0;margin-top:4px}.ct-banquet-dish{display:flex;align-items:center;border:1px solid #ecebe6;border-radius:10px;margin:6px 0;padding:10px 8px;background:#fff;gap:3px}.ct-banquet-dish.selected{border-color:#b0bd9e;background:#f6f8f1}.ct-banquet-dish label{display:grid;grid-template-columns:17px minmax(0,1fr) 46px 67px;align-items:center;gap:9px;flex:1;min-width:0;cursor:pointer;font-size:12px;color:#3e4439}.ct-banquet-dish input{accent-color:#65794c;width:16px;height:16px;margin:0}.ct-banquet-name{line-height:1.4;font-weight:600;overflow-wrap:anywhere}.ct-banquet-name small{display:block;color:#9a9d90;font-size:10px;font-weight:400;margin-top:3px}.ct-banquet-weight{font-size:11px;color:#8a8e82;text-align:right;white-space:nowrap}.ct-banquet-price{text-align:right;white-space:nowrap;font-size:12px}.ct-banquet-dish>button{border:0;background:none;color:#8c947f;padding:5px;font-size:14px}.ct-banquet-summary{border:1px solid #e4e4d9;border-radius:14px;background:#fcfcf8;padding:17px;min-width:0}.ct-banquet-summary h3{font-size:22px;margin:6px 0 18px}.ct-banquet-summary label{font-size:11px;gap:7px}.ct-banquet-summary input{width:100%;border-radius:7px;font-size:15px;background:white}.ct-banquet-metric,.ct-banquet-total{display:grid;grid-template-columns:1fr auto;gap:7px;margin:18px 0}.ct-banquet-metric span,.ct-banquet-total span{font-size:12px;color:#767e6a}.ct-banquet-metric small,.ct-banquet-total small{grid-column:2;text-align:right;font-size:12px;color:#8c927f}.ct-banquet-total{border-top:1px solid #e3e4d9;padding-top:14px}.ct-banquet-total b{font-size:19px}.ct-banquet-summary .primary{width:100%;font-size:12px;background:#43543a;border-color:#43543a;line-height:1.4}.ct-banquet-summary .primary:disabled{opacity:.4;cursor:default}.ct-banquet-help{display:block;font-size:11px;line-height:1.5;color:#919581;margin-top:10px}.ct-banquet-summary details{font-size:11px;line-height:1.5;margin-top:15px}.ct-banquet-summary li{margin:6px 0}.ct-banquet-summary ul{padding-left:16px}.ct-banquet-clear{border:0;background:none;color:#92977f;font-size:11px;padding:12px 0 0}.ct-banquet-estimate-check{flex-direction:row!important;align-items:center;gap:8px;margin-top:12px}.ct-banquet-estimate-check input{width:16px;height:16px}.ct-banquet-summary:focus-within{outline:none} + @container (min-width:760px){.ct-banquet-layout{grid-template-columns:minmax(0,1fr) 230px}.ct-banquet-summary{position:sticky;top:12px}} + #new.sun-banquet-active .catalog{container-type:inline-size;width:100%;min-width:0} + #new.sun-banquet-active #sunCatalogSearchV1775,#new.sun-banquet-active .sun-catalog-view-switch{display:none!important} + @media(max-width:600px){.ct-banquet-intro{align-items:flex-start;flex-direction:column}.ct-banquet h2{font-size:21px}.ct-banquet-packages{grid-template-columns:repeat(2,minmax(0,1fr))}.ct-banquet-dish label{grid-template-columns:16px minmax(0,1fr) 67px;gap:7px}.ct-banquet-name{grid-column:2/4}.ct-banquet-dish input{grid-row:1/3}.ct-banquet-weight{grid-column:2;text-align:left}.ct-banquet-price{grid-column:3}.ct-banquet-preset{align-items:flex-start;flex-direction:column}} + `;document.head.appendChild(style); + window.addEventListener('sun:cloud-tenant-changing',()=>{packageId='all';groupId='all'}); + window.CateriumBanquet={render,bind,editor,saveEditor,packages,group,grams,totals,chooseMenu,toggle,apply}; +})(); diff --git a/public/core/performance.js b/public/core/performance.js index f9057bc..1619e36 100644 --- a/public/core/performance.js +++ b/public/core/performance.js @@ -1,7 +1,7 @@ (()=>{ 'use strict'; const VERSION='17.7.3'; - const RELEASE='20260917-account-access'; + const RELEASE='20260917-banquet-menu'; const hasStoredSession=()=>{try{return Object.keys(localStorage).some(k=>/^sb-.*-auth-token$/i.test(k)&&String(localStorage.getItem(k)||'').length>20)}catch(_){return false}}; function installAuthBoot(){ diff --git a/public/index.html b/public/index.html index bf2a2d7..93ae887 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,4 @@ - - + diff --git a/public/service-worker.js b/public/service-worker.js index e75974a..3df97cb 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,7 +1,7 @@ -const CACHE='sun-catering-pwa-v87-20260917-account-access'; -const VERSION='20260917-account-access'; +const CACHE='sun-catering-pwa-v88-20260917-banquet-menu'; +const VERSION='20260917-banquet-menu'; const CORE=[ - './','./index.html',`./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/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/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}`, './offer-gallery/001.jpg','./offer-gallery/002.jpg', './catalog/001.jpg','./catalog/002.jpg','./catalog/003.jpg', @@ -9,7 +9,7 @@ const CORE=[ './offer-templates/thumb-light.jpg','./offer-templates/thumb-editorial-grid.jpg','./offer-templates/thumb-midnight-glass.jpg','./offer-templates/thumb-emerald-gold.jpg' ]; const CRITICAL_FRESH=new Set([ - '/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js' + '/core/banquet-menu.js','/core/access-policy.js','/core/import-archive.js','/core/company-branding.js','/core/brand-theme.js','/core/sun-safe.js','/core/performance.js','/core/account-center-v1780.js','/core/login-signature-v1776.js','/core/auth-security-v1774.js','/legacy/bootstrap.js','/app-runtime.js' ]); self.addEventListener('install',event=>{ event.waitUntil(caches.open(CACHE).then(cache=>cache.addAll(CORE)).then(()=>self.skipWaiting())); diff --git a/tests/banquet-menu.spec.mjs b/tests/banquet-menu.spec.mjs new file mode 100644 index 0000000..e8bea65 --- /dev/null +++ b/tests/banquet-menu.spec.mjs @@ -0,0 +1,44 @@ +import {test,expect} from '@playwright/test'; +const menu={packageId:'test',packageName:'Тестовое меню',packagePrice:900,packageGrams:300,estimated:true}; +const catalog=[ + {id:'salad',category:6,name:'Салат ',catalogSection:'Салаты',price:300,weight:'100 г',banquet:menu,ingredients:[]}, + {id:'meat',category:6,name:'Горячее мясо',catalogSection:'Горячие блюда',price:600,weight:'200 г',banquet:{...menu,choiceGroup:'main',defaultChoice:true},ingredients:[]}, + {id:'fish',category:6,name:'Горячая рыба',catalogSection:'Горячие блюда',price:600,weight:'200 г',banquet:{...menu,choiceGroup:'main'},ingredients:[]} +]; +test('banquet package selection keeps totals across filters and replaces just one hot main',async({page})=>{ + await page.route('**/index.html',r=>r.fulfill({contentType:'text/html',body:'
'})); + await page.goto('/index.html');await page.addScriptTag({url:'/core/banquet-menu.js'}); + await page.evaluate(items=>{window.menuItems=items;window.menuDraft={lines:[{id:'box',qty:2,price:500}],guestsCount:10};window.drawMenu=()=>{const root=document.getElementById('tiles'),ctx={items:window.menuItems,catalog:window.menuItems,draft:window.menuDraft,rerender:window.drawMenu};root.innerHTML=window.CateriumBanquet.render(ctx);window.CateriumBanquet.bind(root,ctx)};window.drawMenu()},catalog); + await page.locator('[data-banquet-package="test"]').click();await page.locator('[data-banquet-preset]').click(); + await expect(page.locator('[data-banquet-per-guest]')).toHaveText('900 ₽');await expect(page.locator('[data-banquet-weight]')).toHaveText('300 г'); + await page.locator('[data-banquet-group="salads"]').click(); + await expect(page.locator('[data-banquet-row]')).toHaveCount(1);await expect(page.locator('[data-banquet-per-guest]')).toHaveText('900 ₽'); + expect(await page.locator('.ct-banquet img').count()).toBe(0); + await page.locator('[data-banquet-group="main"]').click();await page.locator('[data-banquet-item="fish"]').check(); + await expect(page.locator('[data-banquet-item="meat"]')).not.toBeChecked();await expect(page.locator('[data-banquet-per-guest]')).toHaveText('900 ₽'); + await page.locator('[data-banquet-guests]').fill('12');await expect(page.locator('[data-banquet-total]')).toHaveText('10 800 ₽'); + await page.locator('[data-banquet-apply]').click(); + expect(await page.evaluate(()=>window.menuDraft.lines.map(({id,qty,price})=>({id,qty,price})))).toEqual([{id:'box',qty:2,price:500},{id:'salad',qty:12,price:300},{id:'fish',qty:12,price:600}]); + await page.locator('[data-banquet-apply]').click();expect(await page.evaluate(()=>window.menuDraft.lines.length)).toBe(3); +}); + +test('banquet dishes reach the actual order, preserve quantities on save and show weight in proposals',async({page})=>{ + page.on('dialog',d=>d.accept()); + await page.route('**://api.caterium.ru/**',r=>r.abort());await page.route('**://*.supabase.co/**',r=>r.abort()); + await page.goto('/index.html',{waitUntil:'domcontentloaded'}); + await page.waitForFunction(()=>window.CateriumDataV1773&&window.SunAdminRBACV3&&window.__cateriumOrderEnhancementsV1775); + await page.evaluate(items=>{window.SunCloudV2={getSession:()=>({user:{id:'test'}}),getWorkspace:()=>({id:'test',role:'admin',permissions:{}}),getClient:()=>null,status:()=>({connected:true,signedIn:true,membershipsLoaded:true}),hasPermission:()=>true};boxes.push(...items);window.dispatchEvent(new Event('sun:cloud-permissions-changed'));window.render()},catalog); + await page.getByRole('button',{name:'Банкетное меню',exact:true}).click(); + await page.locator('#sunCatalogSearch').fill('рыба');await expect(page.locator('[data-banquet-row]')).toHaveCount(1);await expect(page.locator('[data-banquet-row="fish"]')).toBeVisible();await page.locator('#sunCatalogSearch').fill(''); + await page.locator('[data-banquet-package="test"]').click();await page.locator('[data-banquet-preset]').click();await page.locator('[data-banquet-guests]').fill('4');await page.locator('[data-banquet-apply]').click(); + expect(await page.evaluate(()=>window.sunBaseOrderTotal(draft))).toBe(3600); + await expect(page.locator('.sun-order-line-row')).toHaveCount(2); + await page.evaluate(()=>{draft.event='Банкет';draft.date='2099-12-20';draft.time='12:00';document.getElementById('date').value='2099-12-20';document.getElementById('time').value='12:00';window.saveOrder()}); + const saved=await page.evaluate(()=>{const o=orders.at(-1);window.openOrder(o.id);return {id:o.id,guests:o.guestsCount,total:o.total,lines:o.lines.map(l=>({id:l.id,qty:l.qty,price:l.price}))}}); + expect(saved.guests).toBe(4);expect(saved.total).toBe(3600);expect(saved.lines).toEqual([{id:'salad',qty:4,price:300},{id:'meat',qty:4,price:600}]); + expect(await page.evaluate(async()=>(await window.sunClientOfferDebugCreateSnapshot(draft)).foodGrams)).toBe(1200); + await page.getByRole('button',{name:'Банкетное меню',exact:true}).click();await page.locator('[data-banquet-edit="salad"]').click(); + await expect(page.locator('#banquetWeightGrams')).toHaveValue('100');await expect(page.locator('#ctBanquetPackage')).toHaveValue('Тестовое меню'); + await page.locator('#banquetWeightGrams').fill('110');await page.locator('#boxPrice').fill('330');await page.evaluate(()=>window.saveBox()); + expect(await page.evaluate(()=>({weight:boxes.find(i=>i.id==='salad').weight,price:boxes.find(i=>i.id==='salad').price,estimated:boxes.find(i=>i.id==='salad').banquet.estimated}))).toEqual({weight:'110 г',price:330,estimated:true}); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 4039189..fe2591d 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs'], + testMatch:['app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, diff --git a/tests/release-check.mjs b/tests/release-check.mjs index d8bf70c..9e6e8fc 100644 --- a/tests/release-check.mjs +++ b/tests/release-check.mjs @@ -14,8 +14,8 @@ check(!index.includes('offer-gallery-data.js'),'blocking Base64 gallery absent') check((runtime.match(/\/Type \/Catalog/g)||[]).length===0,'runtime contains no PDF binary writer'); check(read('core/pdf-engine.js').includes('595.28')&&read('core/pdf-engine.js').includes('841.89'),'PDF engine uses A4 MediaBox'); check([...index.matchAll(/@page\{([^}]*)\}/g)].every(m=>/size:A4/i.test(m[1])),'compact @page rules use A4'); -check(sw.includes('v87-20260917-account-access')&&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('20260917-account-access')&&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('v88-20260917-banquet-menu')&&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('20260917-banquet-menu')&&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('v87-20260917-account-access'),'release manifest points to current PWA cache'); +check(String(releaseManifest.pwaCache||'').includes('v88-20260917-banquet-menu'),'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=12000')&&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('20260917-account-access'),'index cache bust is v17.7.3'); -check(sw.includes('v87-20260917-account-access')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); +check(index.includes('20260917-banquet-menu'),'index cache bust is v17.7.3'); +check(sw.includes('v88-20260917-banquet-menu')&&sw.includes('data-layer-v1773.js')&&sw.includes('server-automation-v1770.js'),'PWA caches v17.7.3 client foundation modules'); check(fs.existsSync(path.join(root,'public/core/data-layer-v1773.js'))&&fs.existsSync(path.join(root,'public/core/server-automation-v1770.js')),'data layer and server automation modules exist'); check(ux.includes('CateriumServerAutomationV1770?.enabled'),'cloud browser auto completion is disabled when server automation is active'); check(runtime.includes("const VERSION = '17.7.3'")&&runtime.includes("v17.7.3 Clients Server Read"),'stability logger reports v17.7.3'); diff --git a/tests/static-security.mjs b/tests/static-security.mjs index 65b4d4e..888c4d4 100644 --- a/tests/static-security.mjs +++ b/tests/static-security.mjs @@ -28,8 +28,8 @@ if(current!==113)fail(`current catalog photo count ${current}, expected 113`);el if(legacyCount!==60)fail(`legacy catalog photo count ${legacyCount}, expected 60`);else ok('60 legacy catalog photos'); const gallery=fs.readdirSync(path.join(pub,'offer-gallery')).filter(x=>/\.jpg$/i.test(x)); if(gallery.length!==2)fail(`offer gallery contains ${gallery.length} jpg files, expected 2`);else ok('offer gallery trimmed'); -if(!sw.includes('20260917-account-access')||!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('20260917-account-access')||!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('20260917-banquet-menu')||!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('20260917-banquet-menu')||!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');