# Файл 1: Code.gs ```javascript /** * AO Inventory — Полная рабочая версия (исправленная). * Создаёт листы, разворачивает Web App, ведёт каталог, движения и остатки. */ const SHEET_CATALOG = 'Catalog'; const SHEET_INVENTORY = 'Inventory'; const SHEET_MOVES = 'Movements'; function onOpen() { const ui = SpreadsheetApp.getUi(); ui.createMenu('AO Inventory') .addItem('Создать/проверить базовые листы', 'ensureSheets') .addToUi(); } function ensureSheets() { const ss = SpreadsheetApp.getActive(); // Catalog let cat = ss.getSheetByName(SHEET_CATALOG) || ss.insertSheet(SHEET_CATALOG); if (cat.getLastRow() === 0) { cat.getRange(1,1,1,4).setValues([[ 'id','name','unit','price' ]]); const demo = [ ['A001','Книга «Правила»','шт',3500], ['A002','Маркер перманентный','шт',450], ['A003','Ручка синяя','шт',120], ['A004','Стакан бумажный','шт',30], ['A005','Кофе 1 кг','пак',6500], ]; cat.getRange(2,1,demo.length,4).setValues(demo); } // Inventory let inv = ss.getSheetByName(SHEET_INVENTORY) || ss.insertSheet(SHEET_INVENTORY); if (inv.getLastRow() === 0) { inv.getRange(1,1,1,5).setValues([[ 'id','name','unit','price','stock' ]]); // Начальные остатки формируем по каталогу (stock=0) const catData = cat.getRange(2,1,cat.getLastRow()-1,4).getValues(); if (catData.length > 0) { const init = catData.map(r => [r[0], r[1], r[2], r[3], 0]); inv.getRange(2,1,init.length,5).setValues(init); } } // Movements let mov = ss.getSheetByName(SHEET_MOVES) || ss.insertSheet(SHEET_MOVES); if (mov.getLastRow() === 0) { mov.getRange(1,1,1,11).setValues([[ 'timestamp','op_type','id','name','unit','price','qty','amount','person','city','notes' ]]); } } /** * Подача HTML интерфейса */ function doGet() { ensureSheets(); return HtmlService .createHtmlOutputFromFile('Index') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) .setTitle('AO Inventory'); } /** * Отдать каталог на клиент. */ function getCatalog() { const ss = SpreadsheetApp.getActive(); const sh = ss.getSheetByName(SHEET_CATALOG); const last = sh.getLastRow(); if (last < 2) return []; const data = sh.getRange(2,1,last-1,4).getValues(); return data.map(r => ({ id: r[0], name: r[1], unit: r[2], price: Number(r[3]) || 0 })); } /** * Принять заказ/движение от клиента, записать в Movements, обновить Inventory. * payload = { * op: 'expense'|'purchase', * items: [{id,name,unit,price,qty}], * person: string, * city: string, * notes: string * } */ function submitOrder(payload) { if (!payload || !payload.items || payload.items.length === 0) { throw new Error('Корзина пуста'); } if (payload.op !== 'expense' && payload.op !== 'purchase') { throw new Error('Некорректный тип операции'); } const ss = SpreadsheetApp.getActive(); const inv = ss.getSheetByName(SHEET_INVENTORY); const mov = ss.getSheetByName(SHEET_MOVES); // Считать текущие остатки в Map по id const invLast = inv.getLastRow(); let map = new Map(); if (invLast >= 2) { const arr = inv.getRange(2,1,invLast-1,5).getValues(); // id,name,unit,price,stock for (const r of arr) map.set(r[0], { id:r[0], name:r[1], unit:r[2], price:Number(r[3])||0, stock:Number(r[4])||0 }); } const ts = new Date(); let totalAmount = 0; const moveRows = []; // Обработка каждой позиции payload.items.forEach(item => { const id = item.id; const qty = Number(item.qty) || 0; const price = Number(item.price) || 0; if (!id || qty === 0) return; let rec = map.get(id); if (!rec) { // Если позиции нет в инвентаре — добавляем. rec = { id, name:item.name, unit:item.unit, price:price, stock:0 }; map.set(id, rec); } if (payload.op === 'expense') { rec.stock = Number(rec.stock) - qty; } else { // purchase rec.stock = Number(rec.stock) + qty; // Обновим цену последней закупки if (price > 0) rec.price = price; } const amount = price * qty; totalAmount += amount; moveRows.push([ ts, payload.op, id, item.name, item.unit, price, qty, amount, payload.person || '', payload.city || '', payload.notes || '' ]); }); // Запись в Movements (одним куском) if (moveRows.length) { mov.insertRowsAfter(1, moveRows.length); // для «свежие сверху» можно и по-другому, но оставим вниз mov.getRange(mov.getLastRow()+1,1,moveRows.length,11).setValues(moveRows); } // Пересоберём Inventory из map (проще всего перезаписать всё) const sorted = Array.from(map.values()).sort((a,b)=> a.id.localeCompare(b.id)); inv.clearContents(); inv.getRange(1,1,1,5).setValues([[ 'id','name','unit','price','stock' ]]); if (sorted.length) { const out = sorted.map(r => [r.id, r.name, r.unit, r.price, r.stock]); inv.getRange(2,1,out.length,5).setValues(out); } return { ok:true, items: payload.items.length, totalAmount, op: payload.op }; } ``` # Файл 2: Index.html ```html AO Inventory

Склад AO — каталог и корзина

Каталог
ID Наименование Ед. Цена Кол-во Действие
Прайс подтягивается из листа Catalog.
Корзина
ID Наименование Ед. Цена Кол-во Сумма

Данные пишутся в лист Movements, а остатки пересчитываются в Inventory.

```