/** * ═══════════════════════════════════════════════════════════ * PREDATOR CONTROL — Apps Script Backend v6 * Ovo je JEDINA verzija koju treba da koristiš. Obriši v4 i v5. * * Izmene u odnosu na v5: * - proveriSistem(): dijagnostika — pokreni je PRVU da vidiš šta fali * - zaštita PIN-a od previše pogrešnih pokušaja (5 → pauza 15 min) * - jasnija AI instrukcija za prodajnu cenu po komadu * * PRVI KORAK: pokreni proveriSistem() i pogledaj log. * ═══════════════════════════════════════════════════════════ */ const SHEETS = { PRODUCTS: 'Proizvodi', SALES: 'Prodaje', COSTS: 'Troskovi', CLAIMS: 'Reklamacije', PURCHASES: 'Nabavke', ADJUSTMENTS: 'Korekcije', PRODUCT_CHANGES: 'Izmene proizvoda', AI_LOG: 'AI Log' }; function doGet() { return HtmlService.createHtmlOutputFromFile('Index') .setTitle('Predator Control') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); } const FIXNI_KURS_EUR = 117.5; // srednji kurs za prikaz radniku i u aplikaciji /** * Vraća istoriju unosa za dashboard. * days = 1 (danas) do 7. Svi korisnici vide sve unose. * Radniku se NE vraćaju obračuni marže/nabavne cene. */ function getHistory(pin, days) { const role = validatePin_(pin); const brojDana = Math.min(Math.max(Number(days) || 1, 1), 7); const sada = new Date(); const odKad = new Date(sada.getFullYear(), sada.getMonth(), sada.getDate() - (brojDana - 1)); const stavke = []; let brojProdaja = 0; let prometRsd = 0; // Prodaje const shSale = getSheet_(SHEETS.SALES); if (shSale.getLastRow() > 1) { const rows = shSale.getRange(2, 1, shSale.getLastRow() - 1, 22).getValues(); rows.forEach(r => { const datum = r[1]; if (!(datum instanceof Date) || datum < odKad) return; const naziv = String(r[4] || r[3] || ''); const kol = Number(r[5]) || 0; const iznos = Number(r[7]) || (Number(r[6]) || 0) * kol; // H = qty*price, ili F*G brojProdaja += 1; prometRsd += iznos; stavke.push({ tip: 'sale', naziv: naziv, kolicina: kol, iznosRsd: iznos, datum: datum.toISOString(), korisnik: String(r[2] || ''), placanje: String(r[15] || '') }); }); } // Reklamacije const shClaim = getSheet_(SHEETS.CLAIMS); if (shClaim.getLastRow() > 1) { const rows = shClaim.getRange(2, 1, shClaim.getLastRow() - 1, 22).getValues(); rows.forEach(r => { const datum = r[1]; if (!(datum instanceof Date) || datum < odKad) return; stavke.push({ tip: 'complaint', naziv: String(r[4] || r[3] || ''), iznosRsd: role === 'owner' ? (Number(r[14]) || 0) : null, // ukupan trošak samo owneru datum: datum.toISOString(), korisnik: String(r[2] || ''), status: String(r[18] || '') }); }); } // Nabavke i korekcije — samo owner vidi if (role === 'owner') { const shPur = getSheet_(SHEETS.PURCHASES); if (shPur.getLastRow() > 1) { const rows = shPur.getRange(2, 1, shPur.getLastRow() - 1, 12).getValues(); rows.forEach(r => { const datum = r[1]; if (!(datum instanceof Date) || datum < odKad) return; stavke.push({ tip: 'purchase', naziv: String(r[6] || r[5] || ''), kolicina: Number(r[7]) || 0, iznosRsd: Number(r[9]) || 0, datum: datum.toISOString(), korisnik: String(r[2] || '') }); }); } } stavke.sort((a, b) => b.datum.localeCompare(a.datum)); return { role: role, kursEur: FIXNI_KURS_EUR, brojDana: brojDana, brojProdaja: brojProdaja, prometRsd: prometRsd, prometEur: Math.round(prometRsd / FIXNI_KURS_EUR), stavke: stavke.slice(0, 50) }; } function getInitialData(pin) { const role = validatePin_(pin); const products = getCatalog_(); // Radnik ne treba da vidi nabavne (cost) cene proizvoda const productsForClient = products.map(p => { const base = { sku: p.sku, name: p.name, category: p.category, aliases: p.aliases, defaultPriceRsd: p.defaultPriceRsd }; if (role === 'owner') { base.costRsd = p.costRsd; base.originalCost = p.originalCost; base.costCurrency = p.costCurrency; } return base; }); return { role: role, kursEur: FIXNI_KURS_EUR, products: productsForClient, paymentMethods: getListColumn_('Liste', 2), costCategories: getListColumn_('Liste', 1), complaintCategories: getListColumn_('Liste', 3), complaintStatuses: getListColumn_('Liste', 4), adjustmentReasons: getListColumn_('Liste', 5) }; } function parseEntry(rawText, pin) { validatePin_(pin); if (!rawText || rawText.trim().length < 3) { throw new Error('Unos je prekratak.'); } const apiKey = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY'); if (!apiKey) { throw new Error('Nedostaje OPENAI_API_KEY u Script Properties.'); } const catalog = getCatalog_(); const catalogForPrompt = catalog.map(p => ({ sku: p.sku, name: p.name, category: p.category, aliases: p.aliases, defaultPriceRsd: p.defaultPriceRsd })); const instructions = [ 'Ti si precizan knjigovodstveni asistent za malu firmu Predator u Srbiji.', 'Iz jedne kratke poruke izdvoji samo jednu poslovnu promenu.', 'Vrste unosa su: sale, cost, complaint, purchase, adjustment, product_update ili unknown.', 'Ako korisnik kaže da IMAMO, NA STANJU JE ili NA LAGERU JE određena količina, koristi product_update i stock_on_hand. To je apsolutno stvarno stanje, nije količina koja se dodaje.', 'Ako korisnik kaže STIGLO JE, NABAVLJENO JE, DODAJ ili UVEZENO JE određena količina, koristi purchase i quantity. Ta količina se dodaje na lager.', 'Ako korisnik kaže NAS KOŠTA, NABAVNA CENA ili CENA KOŠTANJA, koristi product_update i unit_cost_amount + cost_currency.', 'Ako korisnik kaže PRODAJNA CENA, koristi product_update i default_sale_price_rsd.', 'Kod PRODAJE (sale): ako korisnik kaže ukupnu cenu za više komada, podeli je brojem komada i upiši u unit_sale_price_rsd. Ako kaže cenu po komadu, direktno u unit_sale_price_rsd. Uvek popuni unit_sale_price_rsd za prodaju.', 'Brojeve u evrima NE pretvaraj u dinare. Ako je korisnik rekao EUR, upiši valutu EUR i iznos u original_currency_amount.', 'Ako je iznos u dinarima, koristi RSD polja.', 'Proizvod uvek upari sa najbližim artiklom iz kataloga. Ne izmišljaj SKU.', 'Ako nisi siguran u proizvod ili broj, needs_review mora biti true i napiši kratku poruku.', 'Ako je rečeno "Predator set", to najčešće znači Predator CO2 set Alien Tech 450A.', 'Ako je rečeno "manometar", koristi regulator/manometar CO2.', 'Datum se ne mora izdvajati; aplikacija koristi vreme čuvanja.', 'Katalog proizvoda: ' + JSON.stringify(catalogForPrompt) ].join('\n'); const schema = { type: 'object', additionalProperties: false, required: [ 'entry_type','confidence','needs_review','review_message', 'product_sku','product_name','quantity','unit_sale_price_rsd','total_amount_rsd', 'currency','original_currency_amount','payment_method','customer_name','city','phone', 'serial_number','cost_category','description','supplier','part_cost_rsd','labor_cost_rsd', 'shipping_cost_rsd','refund_rsd','other_cost_rsd','supplier_reimbursed', 'supplier_reimbursement_rsd','complaint_status','container_invoice', 'unit_landed_cost_rsd','quantity_change','adjustment_reason', 'unit_cost_amount','cost_currency','exchange_rate_rsd','stock_on_hand', 'default_sale_price_rsd','notes' ], properties: { entry_type: {type: 'string', enum: ['sale','cost','complaint','purchase','adjustment','product_update','unknown']}, confidence: {type: 'number', minimum: 0, maximum: 1}, needs_review: {type: 'boolean'}, review_message: {type: 'string'}, product_sku: {type: ['string','null']}, product_name: {type: ['string','null']}, quantity: {type: ['number','null']}, unit_sale_price_rsd: {type: ['number','null']}, total_amount_rsd: {type: ['number','null']}, currency: {type: 'string', enum: ['RSD','EUR','USD','UNKNOWN']}, original_currency_amount: {type: ['number','null']}, payment_method: {type: ['string','null']}, customer_name: {type: ['string','null']}, city: {type: ['string','null']}, phone: {type: ['string','null']}, serial_number: {type: ['string','null']}, cost_category: {type: ['string','null']}, description: {type: ['string','null']}, supplier: {type: ['string','null']}, part_cost_rsd: {type: ['number','null']}, labor_cost_rsd: {type: ['number','null']}, shipping_cost_rsd: {type: ['number','null']}, refund_rsd: {type: ['number','null']}, other_cost_rsd: {type: ['number','null']}, supplier_reimbursed: {type: ['boolean','null']}, supplier_reimbursement_rsd: {type: ['number','null']}, complaint_status: {type: ['string','null']}, container_invoice: {type: ['string','null']}, unit_landed_cost_rsd: {type: ['number','null']}, quantity_change: {type: ['number','null']}, adjustment_reason: {type: ['string','null']}, unit_cost_amount: {type: ['number','null']}, cost_currency: {type: 'string', enum: ['RSD','EUR','USD','UNKNOWN']}, exchange_rate_rsd: {type: ['number','null']}, stock_on_hand: {type: ['number','null']}, default_sale_price_rsd: {type: ['number','null']}, notes: {type: ['string','null']} } }; const payload = { model: 'gpt-5-mini', store: false, instructions: instructions, input: rawText, text: { format: { type: 'json_schema', name: 'predator_business_entry', strict: true, schema: schema } } }; try { const response = UrlFetchApp.fetch('https://api.openai.com/v1/responses', { method: 'post', contentType: 'application/json', headers: {Authorization: 'Bearer ' + apiKey}, payload: JSON.stringify(payload), muteHttpExceptions: true }); const status = response.getResponseCode(); const body = response.getContentText(); if (status < 200 || status >= 300) { logAi_(rawText, body, 'ERROR', 'OpenAI HTTP ' + status); throw new Error('OpenAI greška ' + status + ': ' + body.substring(0, 400)); } const apiResult = JSON.parse(body); const outputText = extractOutputText_(apiResult); if (!outputText) { logAi_(rawText, body, 'ERROR', 'Nema output_text'); throw new Error('AI nije vratio upotrebljiv rezultat.'); } const parsed = JSON.parse(outputText); parsed.raw_text = rawText; logAi_(rawText, JSON.stringify(parsed), 'PARSED', ''); return parsed; } catch (err) { logAi_(rawText, '', 'ERROR', String(err)); throw err; } } function saveEntry(data, pin, enteredBy) { const role = validatePin_(pin); if (!data || !data.entry_type) throw new Error('Nedostaje vrsta unosa.'); assertCanSave_(role, data.entry_type); const user = enteredBy || 'Radnik'; const now = new Date(); const raw = data.raw_text || ''; const id = createId_(data.entry_type); switch (data.entry_type) { case 'sale': return saveSale_(id, now, user, data, raw); case 'cost': return saveCost_(id, now, user, data, raw); case 'complaint': return saveComplaint_(id, now, user, data, raw); case 'purchase': return savePurchase_(id, now, user, data, raw); case 'adjustment': return saveAdjustment_(id, now, user, data, raw); case 'product_update': return saveProductUpdate_(id, now, user, data, raw); default: throw new Error('Izaberi ispravnu vrstu unosa.'); } } function saveSale_(id, now, user, d, raw) { requireProduct_(d.product_sku); const qty = num_(d.quantity, 1); let unitPrice = numOrBlank_(d.unit_sale_price_rsd); if (unitPrice === '' && d.total_amount_rsd != null && qty) { unitPrice = Number(d.total_amount_rsd) / qty; } if (unitPrice === '') throw new Error('Unesi prodajnu cenu.'); const row = [ id, now, user, d.product_sku, '', qty, unitPrice, '', 0, 0, 0, '', '', '', '', d.payment_method || '', d.customer_name || '', d.city || '', d.phone || '', d.serial_number || '', d.notes || d.description || '', raw ]; const sh = getSheet_(SHEETS.SALES); sh.appendRow(row); const r = sh.getLastRow(); sh.getRange(r, 5).setFormula(`=IFERROR(VLOOKUP(D${r},Proizvodi!$A:$T,2,FALSE),"")`); sh.getRange(r, 8).setFormula(`=IF(OR(F${r}="",G${r}=""),"",F${r}*G${r})`); sh.getRange(r, 12).setFormula(`=IF(H${r}="","",H${r}-IF(I${r}="",0,I${r})+IF(J${r}="",0,J${r})-IF(K${r}="",0,K${r}))`); sh.getRange(r, 13).setFormula(`=IFERROR(VLOOKUP(D${r},Proizvodi!$A:$T,6,FALSE),0)`); sh.getRange(r, 14).setFormula(`=IF(OR(F${r}="",M${r}=""),"",F${r}*M${r})`); sh.getRange(r, 15).setFormula(`=IF(L${r}="","",L${r}-N${r})`); return {ok: true, id: id, message: 'Prodaja je sačuvana.'}; } function saveCost_(id, now, user, d, raw) { let amount = numOrBlank_(d.total_amount_rsd); if (amount === '' && d.currency === 'RSD') amount = numOrBlank_(d.original_currency_amount); if (amount === '') throw new Error('Unesi iznos troška u RSD.'); const row = [ id, now, user, d.cost_category || 'Ostalo', d.description || d.notes || '', d.supplier || '', amount, d.product_sku || '', '', '', d.payment_method || '', '', d.notes || '', raw ]; const sh = getSheet_(SHEETS.COSTS); sh.appendRow(row); const r = sh.getLastRow(); sh.getRange(r, 9).setFormula(`=IFERROR(VLOOKUP(H${r},Proizvodi!$A:$T,2,FALSE),"")`); return {ok: true, id: id, message: 'Trošak je sačuvan.'}; } function saveComplaint_(id, now, user, d, raw) { requireProduct_(d.product_sku); const row = [ id, now, user, d.product_sku, '', '', d.serial_number || '', d.cost_category || 'Ostalo', d.description || '', num_(d.part_cost_rsd,0), num_(d.labor_cost_rsd,0), num_(d.shipping_cost_rsd,0), num_(d.refund_rsd,0), num_(d.other_cost_rsd,0), '', d.supplier_reimbursed === true ? 'DA' : (d.supplier_reimbursed === false ? 'NE' : 'NEPOZNATO'), num_(d.supplier_reimbursement_rsd,0), '', d.complaint_status || 'Otvorena', '', d.notes || '', raw ]; const sh = getSheet_(SHEETS.CLAIMS); sh.appendRow(row); const r = sh.getLastRow(); sh.getRange(r, 5).setFormula(`=IFERROR(VLOOKUP(D${r},Proizvodi!$A:$T,2,FALSE),"")`); sh.getRange(r, 15).setFormula(`=IF(COUNTA(J${r}:N${r})=0,"",SUM(J${r}:N${r}))`); sh.getRange(r, 18).setFormula(`=IF(O${r}="","",O${r}-IF(Q${r}="",0,Q${r}))`); return {ok: true, id: id, message: 'Reklamacija je sačuvana.'}; } function savePurchase_(id, now, user, d, raw) { requireProduct_(d.product_sku); const qty = num_(d.quantity, 1); const unitCost = numOrBlank_(d.unit_landed_cost_rsd); if (unitCost === '') throw new Error('Unesi cenu koštanja po komadu u RSD.'); const row = [ id, now, user, d.supplier || '', d.container_invoice || '', d.product_sku, '', qty, unitCost, '', d.notes || '', raw ]; const sh = getSheet_(SHEETS.PURCHASES); sh.appendRow(row); const r = sh.getLastRow(); sh.getRange(r, 7).setFormula(`=IFERROR(VLOOKUP(F${r},Proizvodi!$A:$T,2,FALSE),"")`); sh.getRange(r, 10).setFormula(`=IF(OR(H${r}="",I${r}=""),"",H${r}*I${r})`); return {ok: true, id: id, message: 'Nabavka je sačuvana.'}; } function saveAdjustment_(id, now, user, d, raw) { requireProduct_(d.product_sku); const change = numOrBlank_(d.quantity_change); if (change === '') throw new Error('Unesi promenu količine, npr. -1 ili +5.'); const row = [ id, now, user, d.product_sku, '', change, d.adjustment_reason || 'Ostalo', d.notes || d.description || '', raw ]; const sh = getSheet_(SHEETS.ADJUSTMENTS); sh.appendRow(row); const r = sh.getLastRow(); sh.getRange(r, 5).setFormula(`=IFERROR(VLOOKUP(D${r},Proizvodi!$A:$T,2,FALSE),"")`); return {ok: true, id: id, message: 'Korekcija lagera je sačuvana.'}; } function saveProductUpdate_(id, now, user, d, raw) { requireProduct_(d.product_sku); const product = findProduct_(d.product_sku); const productSheet = getSheet_(SHEETS.PRODUCTS); const rowNumber = product.rowNumber; let originalCost = numOrBlank_(d.unit_cost_amount); let currency = (d.cost_currency || d.currency || 'RSD').toUpperCase(); if (!['RSD','EUR','USD'].includes(currency)) currency = 'RSD'; let rate = numOrBlank_(d.exchange_rate_rsd); let costRsd = ''; if (originalCost !== '') { if (currency === 'RSD') { rate = 1; } else if (rate === '') { rate = getFxRate_(currency); } costRsd = Number(originalCost) * Number(rate); productSheet.getRange(rowNumber, 6).setValue(costRsd); // F Cena koštanja RSD productSheet.getRange(rowNumber, 11).setValue(originalCost); // K original productSheet.getRange(rowNumber, 12).setValue(currency); // L valuta productSheet.getRange(rowNumber, 13).setValue(rate); // M kurs productSheet.getRange(rowNumber, 14).setValue(now); // N izmena } const salePrice = numOrBlank_(d.default_sale_price_rsd); if (salePrice !== '') { productSheet.getRange(rowNumber, 7).setValue(salePrice); productSheet.getRange(rowNumber, 14).setValue(now); } const previousStock = getCurrentStock_(d.product_sku); const targetStock = numOrBlank_(d.stock_on_hand); let stockDelta = ''; if (targetStock !== '') { stockDelta = Number(targetStock) - Number(previousStock); if (Math.abs(stockDelta) > 0.000001) { const adjustmentId = createId_('adjustment'); getSheet_(SHEETS.ADJUSTMENTS).appendRow([ adjustmentId, now, user, d.product_sku, '', stockDelta, 'Inventura', 'Glasovno postavljeno stvarno stanje na ' + targetStock + '. Prethodno izračunato stanje: ' + previousStock + '.', raw ]); } } getSheet_(SHEETS.PRODUCT_CHANGES).appendRow([ id, now, user, d.product_sku, product.name, originalCost, currency, rate, costRsd, salePrice, previousStock, targetStock, stockDelta, d.notes || d.description || '', raw ]); const parts = []; if (originalCost !== '') parts.push('cena koštanja ' + originalCost + ' ' + currency); if (salePrice !== '') parts.push('prodajna cena ' + salePrice + ' RSD'); if (targetStock !== '') parts.push('stvarno stanje ' + targetStock); return { ok: true, id: id, message: 'Proizvod je ažuriran' + (parts.length ? ': ' + parts.join(', ') : '.') }; } function getCatalog_() { const sh = getSheet_(SHEETS.PRODUCTS); const last = sh.getLastRow(); if (last < 2) return []; const values = sh.getRange(2,1,last-1,14).getValues(); return values .map((r, index) => ({r: r, rowNumber: index + 2})) .filter(x => String(x.r[4]).toUpperCase() === 'DA') .map(x => ({ sku: String(x.r[0]), name: String(x.r[1]), category: String(x.r[2]), aliases: String(x.r[3] || ''), costRsd: x.r[5] || null, defaultPriceRsd: x.r[6] || null, warrantyDays: x.r[7] || null, originalCost: x.r[10] || null, costCurrency: x.r[11] || null, exchangeRateRsd: x.r[12] || null, rowNumber: x.rowNumber })); } function findProduct_(sku) { const product = getCatalog_().find(p => p.sku === sku); if (!product) throw new Error('Nepoznat ili neaktivan SKU: ' + sku); return product; } function getFxRate_(currency) { if (currency === 'RSD') return 1; const settingName = currency === 'EUR' ? 'Kurs EUR/RSD' : 'Kurs USD/RSD'; const sh = getSheet_('Podesavanja'); const values = sh.getRange(1,1,sh.getLastRow(),2).getValues(); for (let i = 0; i < values.length; i++) { if (String(values[i][0]).trim() === settingName) { const rate = Number(values[i][1]); if (rate > 0) return rate; } } throw new Error('U listu Podesavanja upiši važeći ' + settingName + '.'); } function getCurrentStock_(sku) { const product = findProduct_(sku); const shProducts = getSheet_(SHEETS.PRODUCTS); const opening = Number(shProducts.getRange(product.rowNumber, 9).getValue()) || 0; const purchased = sumBySku_(SHEETS.PURCHASES, 6, 8, sku); const sold = sumBySku_(SHEETS.SALES, 4, 6, sku); const adjusted = sumBySku_(SHEETS.ADJUSTMENTS, 4, 6, sku); return opening + purchased - sold + adjusted; } function sumBySku_(sheetName, skuColumn, valueColumn, sku) { const sh = getSheet_(sheetName); const last = sh.getLastRow(); if (last < 2) return 0; const width = Math.max(skuColumn, valueColumn); const rows = sh.getRange(2,1,last-1,width).getValues(); return rows.reduce((sum, row) => { if (String(row[skuColumn - 1]) !== String(sku)) return sum; return sum + (Number(row[valueColumn - 1]) || 0); }, 0); } function getListColumn_(sheetName, columnNumber) { const sh = getSheet_(sheetName); const last = sh.getLastRow(); if (last < 2) return []; return sh.getRange(2,columnNumber,last-1,1).getValues() .flat() .filter(v => String(v).trim() !== ''); } function getSheet_(name) { const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(name); if (!sh) throw new Error('Nedostaje list: ' + name); return sh; } function requireProduct_(sku) { if (!sku) throw new Error('Izaberi proizvod.'); const exists = getCatalog_().some(p => p.sku === sku); if (!exists) throw new Error('Nepoznat ili neaktivan SKU: ' + sku); } /** * Proverava PIN i vraća ulogu korisnika: 'owner' ili 'worker'. * OWNER_PIN = tvoj PIN (pun pristup). * WORKER_PIN = radnikov PIN (samo prodaje i reklamacije, bez obračuna). * Ako je podešen samo WORKER_PIN (stari sistem), on se tretira kao owner * radi kompatibilnosti dok ne dodaš OWNER_PIN. */ function validatePin_(pin) { const props = PropertiesService.getScriptProperties(); const ownerPin = props.getProperty('OWNER_PIN'); const workerPin = props.getProperty('WORKER_PIN'); if (!ownerPin && !workerPin) { throw new Error('Nedostaje OWNER_PIN i WORKER_PIN u Script Properties.'); } const cache = CacheService.getScriptCache(); const key = 'pin_fails'; const fails = Number(cache.get(key) || 0); if (fails >= 5) { throw new Error('Previše pogrešnih pokušaja. Sačekaj 15 minuta.'); } const entered = String(pin || ''); let role = null; if (ownerPin && entered === String(ownerPin)) role = 'owner'; else if (workerPin && entered === String(workerPin)) role = 'worker'; // Kompatibilnost: ako OWNER_PIN nije podešen, WORKER_PIN je owner if (role === 'worker' && !ownerPin) role = 'owner'; if (!role) { cache.put(key, String(fails + 1), 900); throw new Error('Pogrešan PIN.'); } if (fails > 0) cache.remove(key); return role; } /** Uloge kojima je dozvoljen svaki tip unosa. Radnik: samo prodaja i reklamacija. */ function assertCanSave_(role, entryType) { if (role === 'owner') return; const dozvoljeno = ['sale', 'complaint']; if (!dozvoljeno.includes(entryType)) { throw new Error('Radnik može da unosi samo prodaje i reklamacije.'); } } function extractOutputText_(response) { const output = response.output || []; for (let i = 0; i < output.length; i++) { const content = output[i].content || []; for (let j = 0; j < content.length; j++) { if (content[j].type === 'output_text') return content[j].text; } } return ''; } function createId_(type) { const prefixes = { sale: 'PROD', cost: 'TRO', complaint: 'REK', purchase: 'NAB', adjustment: 'KOR', product_update: 'PROIZ' }; return (prefixes[type] || 'UNOS') + '-' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyyMMdd-HHmmss') + '-' + Utilities.getUuid().substring(0,4).toUpperCase(); } function num_(value, fallback) { if (value === null || value === undefined || value === '') return fallback; const n = Number(value); return isNaN(n) ? fallback : n; } function numOrBlank_(value) { if (value === null || value === undefined || value === '') return ''; const n = Number(value); return isNaN(n) ? '' : n; } function logAi_(raw, parsed, status, error) { try { getSheet_(SHEETS.AI_LOG).appendRow([ new Date(), 'Web aplikacija', raw || '', parsed || '', status || '', error || '' ]); } catch (e) { console.log(e); } } /** * API endpoint for the external Predator Control frontend. * Expected JSON: * { * secret: "...", * action: "initial" | "parse" | "save", * pin: "...", * enteredBy: "Miloš", * text: "...", * data: {...} * } */ function doPost(e) { try { const body = JSON.parse((e && e.postData && e.postData.contents) || '{}'); validateApiSecret_(body.secret); let result; switch (body.action) { case 'initial': result = getInitialData(body.pin); break; case 'parse': result = parseEntry(body.text || '', body.pin); break; case 'save': result = saveEntry(body.data || {}, body.pin, body.enteredBy || 'Radnik'); break; case 'history': result = getHistory(body.pin, body.days || 1); break; case 'health': result = { ok: true, service: 'Predator Control API' }; break; default: throw new Error('Nepoznata API akcija.'); } return jsonResponse_({ ok: true, result: result }); } catch (err) { return jsonResponse_({ ok: false, error: err && err.message ? err.message : String(err) }); } } function validateApiSecret_(secret) { const expected = PropertiesService.getScriptProperties().getProperty('API_SHARED_SECRET'); if (!expected) throw new Error('Nedostaje API_SHARED_SECRET u Script Properties.'); if (String(secret || '') !== String(expected)) throw new Error('Nevažeći API secret.'); } function jsonResponse_(payload) { return ContentService .createTextOutput(JSON.stringify(payload)) .setMimeType(ContentService.MimeType.JSON); } function testConfiguration() { const props = PropertiesService.getScriptProperties(); const result = { hasApiKey: Boolean(props.getProperty('OPENAI_API_KEY')), hasOwnerPin: Boolean(props.getProperty('OWNER_PIN')), hasWorkerPin: Boolean(props.getProperty('WORKER_PIN')), hasApiSharedSecret: Boolean(props.getProperty('API_SHARED_SECRET')), activeProducts: getCatalog_().length }; console.log(JSON.stringify(result)); return result; } /** * ═══════════════════════════════════════════════════════════ * DIJAGNOSTIKA — pokreni OVU funkciju jednom iz Apps Script menija * (izaberi "proveriSistem" gore u dropdown-u pa klikni Run). * Pogledaj Execution log (View > Logs). Reći će ti tačno šta fali. * ═══════════════════════════════════════════════════════════ */ function proveriSistem() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const problemi = []; const ok = []; // 1. Provera Script Properties const props = PropertiesService.getScriptProperties(); const kljucevi = ['OPENAI_API_KEY','OWNER_PIN','WORKER_PIN','API_SHARED_SECRET']; kljucevi.forEach(k => { if (props.getProperty(k)) ok.push('✓ Postoji ključ: ' + k); else if (k === 'WORKER_PIN') problemi.push('⚠ Nema WORKER_PIN (radnik neće moći da se prijavi; owner radi i bez njega)'); else problemi.push('✗ FALI ključ u Script Properties: ' + k); }); // 2. Provera da svi listovi postoje const potrebniListovi = Object.values(SHEETS).concat(['Proizvodi','Liste','Podesavanja']); const jedinstveni = [...new Set(potrebniListovi)]; jedinstveni.forEach(name => { const sh = ss.getSheetByName(name); if (sh) ok.push('✓ List postoji: ' + name + ' (' + sh.getLastRow() + ' redova, ' + sh.getLastColumn() + ' kolona)'); else problemi.push('✗ FALI list: ' + name); }); // 3. Provera kataloga proizvoda try { const katalog = getCatalog_(); if (katalog.length === 0) problemi.push('✗ Nema aktivnih proizvoda (kolona E mora biti "DA")'); else { ok.push('✓ Aktivnih proizvoda: ' + katalog.length); const prvi = katalog[0]; ok.push(' Primer: ' + prvi.sku + ' = ' + prvi.name + ', cena: ' + (prvi.defaultPriceRsd || 'NEMA')); } } catch (e) { problemi.push('✗ Greška pri čitanju proizvoda: ' + e.message); } // 4. Provera širine lista Prodaje (kod piše u kolone do 15) const prodaje = ss.getSheetByName(SHEETS.SALES); if (prodaje) { if (prodaje.getLastColumn() < 22 && prodaje.getLastRow() > 0) { problemi.push('⚠ List "Prodaje" ima samo ' + prodaje.getLastColumn() + ' kolona sa podacima. Kod koristi kolone A–V (22). Proveri header red.'); } else { ok.push('✓ List Prodaje izgleda dovoljno širok'); } } // 5. Provera FX kurseva try { const eur = getFxRate_('EUR'); ok.push('✓ Kurs EUR/RSD: ' + eur); } catch (e) { problemi.push('⚠ Kurs EUR nije podešen (potreban samo za product_update u EUR): ' + e.message); } // Ispis const izvestaj = '═══════ REZULTAT PROVERE ═══════\n\n' + 'ŠTA JE U REDU:\n' + (ok.length ? ok.join('\n') : '(ništa)') + '\n\n' + 'ŠTA TREBA POPRAVITI:\n' + (problemi.length ? problemi.join('\n') : '(nema problema — sistem je spreman!)') + '\n\n═══════════════════════════════'; console.log(izvestaj); return izvestaj; }