# Файл 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.
Данные пишутся в лист Movements, а остатки пересчитываются в Inventory.
```