Sending stock alert emails using google apps script - google-apps-script

I am trying to setup a simple google apps script to send emails when inventory on various components hits the defined threshold. After reading and searching I put together a script that appears to work but starts to send erroneous emails when I increase the range being pulled from my spreadsheet.
Here's my code and further details are below.
var ss = SpreadsheetApp.getActive();
var sheet = ss.getSheetByName("Inventory Report");
var howFar = 5 //sheet.getMaxRows(); // how many rows of data
var onHand = sheet.getRange(2,14,howFar,1).getValues();
var minimum = sheet.getRange(2,16,howFar,1).getValues();
var itemNum = sheet.getRange(2,1,howFar,1).getValues();
var itemName = sheet.getRange(2,2,howFar,1).getValues();
var sendTo = "test#gmail.com";
var overStock = 1.5; // warning factor over minimum
function stockAlert() {
for (var i = 0; i < onHand.length; i++) {
if (onHand[i] < minimum[i] * overStock && onHand[i] > minimum[i]) {
MailApp.sendEmail( sendTo , "Testing Stock Reorder Notifications", itemNum[i] + " " + itemName[i] +" - Stock is low! Current stock is " + onHand[i] + ". Minimum is " + minimum[i] + ".");
}
else if (minimum[i] > 0 && onHand[i] < minimum[i]) {
MailApp.sendEmail( sendTo , "Testing Stock Cirtical Notifications", itemNum[i] + " " + itemName[i] +" - Stock is Critical and will be depleted! Current stock is " + onHand[i] + ". Minimum is " + minimum[i] + ".");
}
}
}
In my sheet the values for minimum are 200, 400, 200, 300, 600
In my sheet the values for onHand are 270, 270, 920, 920, 1830
This means I should see one low stock email for the first set of values, one critical stock email for the second set of values and no emails for the last 3 sets of values.
If var howfar = 3, the script sends the two appropriate emails.
If var howfar = 5, I get a third critical stock email for the fifth set of values that should not be sent. Interestingly the email body shows that it's referencing the correct set of values but else if should be false.
The body of the incorrect email reads:
itemNum itemName - Stock is Critical and will be depleted! Current stock is 1830. Minimum is 600.
Given my extensive background in not coding, I hope and assume this will be a simple fix, but any all help is greatly appreciated!

Any chance the values are treated as text in the spreadsheet? Note that the string "1830" is indeed < the string "600". If they are text in the spreadsheet (rather than numbers), then when Apps Script reads the values in, they will be kept as Strings.
edit: indeed, this is the source of your issue - you compare 2D arrays at the Array level:
Logger.log(minimum[i]); // "[600.0]"
Logger.log(typeof minimum[i]); // object
Logger.log(minimum[i][0]); // 600.0
Logger.log(typeof minimum[i][0]); // number
The simplest fix is then to simply access the desired element of the 2D array. Since you acquired only single columns, there is only 1 element in each inner array (at index 0). Thus, <array>[i][0] instead of <array>[i].
An extension to this, which will work for situations in which the sheet values may be text, is to explicitly cast to a number before comparing by using the JS function parseInt(val, radix). Assuming minimum and others are 2D arrays as they are in your question code:
for (var i = 0; i < onHand.length; i++) {
var min = parseInt(minimum[i][0], 10),
avail = parseInt(onHand[i][0], 10);
if (avail < min) {
// send critical stock email
}
else if (avail < min * overStock) {
// send reorder email
}
else {
// on hand amount is > needed
}
}
For a blank string, e.g. parseInt("", 10), or other non-numeric inputs the return value is the number NaN which is neither > or < than actual numbers, so bad inputs should not result in an email being sent.
A different issue is that your script populates global variables with interface calls, which results in slower execution of any script. A better approach is to wrap the related setup in a function:
function getStockAmounts() {
// Return an object of the inventory values.
const stock = SpreadsheetApp.getActive().getSheetByName("some name");
const numHeaders = 1,
numVals = stock.getLastRow() - numHeaders;
return [
{p: 'minimum', col: 16},
{p: 'onHand', col: 14},
{p: 'itemName', col: 2},
{p: 'itemNum', col: 1}
].reduce(function (obj, key) {
obj[key.p] = stock.getRange(numHeaders + 1, key.col, numVals, 1)
.getValues()
// Return a 1-D array, rather than a 2-D array, since all these are single-column variables.
.map(function (row) { return row[0]; });
return obj;
}, {'numVals': numVals});
}
and then call this from your script:
function foo() {
const stocks = getStockAmounts();
for (var i = 0; i < stocks.numVals; i++) {
var min = stocks.minimum[i]; // 1D arrays, so only 1 index is needed.
...
Range#getValues()
Array#reduce

Related

multiple users using google spreadsheet as data entry form and Database, overwriting each others data

I want to make a sales entry system with google spreadsheet for multiple users.
User 1 will use the data entry form tab named "Main", using inputdata1()
User 2 will use the data entry form tab named "Sub", using inputdata2()
Each of them will write data in a new row that I found it by using the getlastrowspecial function (e.g. it this case lets say its row 10)
If both of them execute the code simultaneously. Sometimes I will see User 1's data being written on row 10 and User 2's data overwriting User 1's data in the same row on row 10. The checking to see if the row is empty before written is not working. I wanted to keep User 2 to wait until User 1's code is completely executed. I dont mind if User 2 need to call the function again.
Please help if there is any method to do this. Thank you very much!
var ss = SpreadsheetApp.getActiveSpreadsheet()
var db = ss.getSheetByName("DB")
var mainInput = ss.getSheetByName("Main")
var subInput = ss.getSheetByName("Sub")
function inputData1() {
var input1 = mainInput.getRange("E2:I2").getValues()
Logger.log(input1)
var lr = getLastRowSpecial(db, "A1:I")
Logger.log(lr)
if (db.getRange(lr + 1, 5).getValue() !== "") {
SpreadsheetApp.getUi().alert("Please try again")
return
} else {
db.getRange(lr + 1, 5, 1, input1[0].length).setValues(input1)
}
}
function inputData2() {
var input2 = subInput.getRange("E2:I2").getValues()
var lr = getLastRowSpecial(db, "A1:I")
if (db.getRange(lr + 1, 5).getValue() !== "") {
SpreadsheetApp.getUi().alert("Please try again")
return
} else {
db.getRange(lr + 1, 5, 1, input2[0].length).setValues(input2)
}
}
// following function getLastRowSpecial() for getting the last row with blank row"
function getLastRowSpecial(sheetlastrow, rangeString) {
var rng = sheetlastrow.getRange(rangeString).getValues();
var lrIndex;
for (var i = rng.length - 1; i >= 0; i--) {
lrIndex = i;
if (!rng[i].every(function (c) { return c == ""; })) {
break;
}
}
return lrIndex + 1;
}
Replace Range.setValues() with Sheet.appendRow(), like this:
current:
db.getRange(lr + 1, 5, 1, input1[0].length).setValues(input1)
new:
db.appendRow(input1[0]);
If you need the values to start in column E, use this:
db.appendRow([null, null, null, null].concat(input1[0]));
Alternatively, follow Cooper's advice and use the Lock Service. If you choose to go this route, also consider using the appendRows_() utility function.

Can a GAS Library be used a script template?

I've built a system within a spreadsheet that uses custom menus to move through a process. The script is stored in a template file, which is generated from a separate overview spreadsheet (via script) when required. Basically, each newly generated spreadsheet is a Product Tracker.
When the template is copied for a new Product Tracker the script is out-of-date if the template is updated. Would extracting the bound script from the template and creating a library from it make sense for my application?
I've nearly finished developing the template for a particular product, but I would like to roll it out for multiple products with slightly different templates which means it would be difficult to have a generic script that could be published within my Google Workspace Domain as an add-on (would probably need an add-on for each product).
Reading up on Libraries, Google warns that they can be slow. Currently, each script takes around 10 seconds to run and performs operations such as hide/show sheets, change tab colours, add/remove protection, send emails etc. There are already calls to another library, would this affect it as well? Would this be really slow as a library?
Thanks in advance!
Edit:
There are 10 buttons on the menu, one for each stage. The API's below are used at every stage. Reading through the execution logs, the scripts vary in execution time from 4 - 6 seconds depending on the stage.
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/script.container.ui"
],
Edit2:
Bastardised the code to remove certain information.
The script below is similar across around 9 scripts.
function doThing() {
var activeUser = Session.getActiveUser().getEmail();
var activeUserName = activeUser.split('#domain.com')[0]
// spreadsheets
const ss = SpreadsheetApp.getActive();
// sheets
const 1 = ss.getSheetByName("1.");
const 1b= ss.getSheetByName("1b.");
const 2 = ss.getSheetByName("2.");
const 3 = ss.getSheetByName("3.");
const 4 = ss.getSheetByName("4.");
const 5 = ss.getSheetByName("5.");
const 6 = ss.getSheetByName("6.");
const 7 = ss.getSheetByName("7.");
const 8 = ss.getSheetByName("8");
const 9 = ss.getSheetByName("9.");
const shipSheet = ss.getSheetByName("Shipping");
const buildEvalSheet = ss.getSheetByName("Build Evaluation");
// build Number
const buildNumber = 1.getRange(buildNumberCell).getValue();
// get the active user email address and log it
Logger.log("Active User Email: " + activeUser);
Logger.log("Running 3");
// this prevents non-authorised users from running the script
// see the permissionGroups object in the Variables script file for more information
// set a variable relative to the department using the buttons
var criteria = 'department'
// run the permissions function in the library
var automationPermission = EmailPermissionsLibrary.checkPermissions(activeUserName, criteria);
// run if true
if (automationPermission) {
// set tab colours for previous & current
1.setTabColor("GREEN");
1b.setTabColor("GREEN");
2.setTabColor("GREEN");
3.setTabColor("GREEN");
// protect
3.activate()
protect();
statusUpdate()
if (emailBoolean) {
// send information to Sales and Shipping
//create html email
var htmlEmail = "<html><body>";
htmlEmail += "<p>Hello,</p>"
htmlEmail += "<p>" + buildNumber + " message</p>"
// send email
MailApp.sendEmail({
to: 'email#domain.com',
subject: buildNumber + " message",
htmlBody: htmlEmail,
replyTo: activeUser
});
}
shipping()
buildEvalShow()
}
// error message if the user is not authorised
else {
SpreadsheetApp.getUi().alert('This account does not have authorisation')
Logger.log(activeUser + " does not have required permissions")
}
}
Will this script be slow as a library?
// set a variable relative to the department using the buttons
var criteria = 'department'
// run the permissions function in the library
var automationPermission = EmailPermissionsLibrary.checkPermissions(activeUserName, criteria);
// log permission
Logger.log(automationPermission)
// run if true
if (automationPermission) {
Logger.log("Running");
// show sheet
sheet.activate();
// check that the #### hasn't already ran by doing a row count
// if it hasn't, the following should return 1 which will be the titles
var sheetR = sheet.getLastRow();
if (sheetR <= 1) {
// summary table size
const sumTableRowCount = 12;
const sumTableColCount = 3;
for (i = 1; i <= 3; i++) {
var results = eval("prodQC" + [i] + "Sheet");
sumTable = sheet2.getRange(45, 2, sumTableRowCount, sumTableColCount).getValues();
var something = sheet2.getLastRow() + 2;
// create the title
var title = "Title " + i;
// write the title
sheet.getRange(sheetR, 1).setValue(title);
// Remove checks
if (i >= 2) {
var sheetRL= sheet.getLastRow();
sheet.getRange(buildEvalSheetLastRow, 4).removeCheckboxes();
sheet.getRange(buildEvalSheetLastRow, 6).removeCheckboxes();
}
// recount last row and add 1 to stop overwrite
var sheetR = sheet.getLastRow() + 1;
// write the QC summary table to the sheet
sheet.getRange(sheetR, 1, sumTableRowCount, sumTableColCount).setValues(sumTable);
// set checkboxes next to values on JIRA col
sheet.getRange(sheetR, 4, sumTableRowCount, 1).insertCheckboxes();
// set checkboxes next to values on the CPACC col
sheet.getRange(sheetR, 6, sumTableRowCount, 1).insertCheckboxes();
// add data validation to the Choose One col
// last row
var sheetR = sheet.getLastRow();
// set range to be from row 3, col 8, to last row
var range = sheet.getRange(4, 8, sheetR, 1);
// set the data validation to only accept values from the array
range .setDataValidation(SpreadsheetApp.newDataValidation()
.setAllowInvalid(false)
.requireValueInList(
[
"list item 1",
"list item 2",
"listed item 3"
],
true)
.build()
);
// add data validation to the Impact and Deadline cols
// set range to be from row 3, col 11, to last row for 2 cols
var cols = sheet.getRange(4, 11, sheetR, 2);
// set the data validation to only accept values from the array
cols .setDataValidation(SpreadsheetApp.newDataValidation()
.setAllowInvalid(false)
.requireValueInList(
[
"1",
"2",
"3",
"4",
"5"
],
true)
.build()
);
}
// remove all non-failures
// last row
var sheetR = sheet.getLastRow();
// range of values from A1, across 3 columns and to the last row
var range = sheet.getRange(1, 1, sheetR, 3);
// Default text from Production QC Template
var removeVal1 = "something";
// Modified text when the QC is complete
var removeVal2 = "something else";
// Default text from Production QC Template
var removeVal3 = "another thing";
// get values from the range to check in the For loop below
var rangeVals = range.getValues();
// Reverse the 'for' loop.
for (let i = rangeVals.length - 1; i >= 0; i--) {
// if matching values above are in the target range, delete the row
if (
rangeVals[i][2] === removeVal1 ||
rangeVals[i][2] === removeVal2 ||
rangeVals[i][2] === removeVal3) {
buildEvalSheet.deleteRow(i + 1)
}
}
} else { // if there is already more rows than expected).alert('this has done something');
}
} else { // error message if the user is not authorised
SpreadsheetApp.getUi().alert('This account does not have authorisation')
Logger.log(activeUser + " does not have required permissions")
}
}

Improve loading efficiency of App Script code in Google Sheets

ISSUE
I have a spreadsheet whereby I generate the end column based on the other columns present. I do this using the app script code below. There are now 1147 rows in this spreadsheet and I often notice a long period of loading to retrieve all of the rows.
Are there any suggestions on how I can improve the efficiency and responsiveness?
EXAMPLE
ARRAY FORMULA ON END COLUMN
=ARRAYFORMULA(
IF(A2:A="Spec", "# SPEC "&B2:B,
IF(A2:A="Scenario", "## "&B2:B,
IF(A2:A="Step", "* "&TAGS(C2:C,D2:D),
IF(A2:A="Tag", "Tags: "&REGEXREPLACE(B2:B,"\s",""),
IF(A2A="", ""))))))
APP SCRIPT CODE
Utilities.sleep(3000)
/** #OnlyCurrentDoc */
function TAGS(input,textreplacement) {
if (input.length > 0) {
var lst = input.split(",")
var rep = textreplacement.match(/<[^>]*>/g)
for (i in rep){
textreplacement = textreplacement.replace(rep[i],'"'+lst[i]+'"')
}
return textreplacement
}
else{
return textreplacement
}
}
EDIT
From the image below I would like to replace everything with triangle brackets < > in column D, with the values in column C, separated by comma.
I use the Array Formula in column E to do an initial conversion and then use the TAGS function to add in the values.
Ideally I would use the Array Formula in one cell at the top of column E to do all the replacements.
Custom functions in Google Apps Script tend to take long time to process and I wouldn't recommend to use it in several cells. I would like to understand better what you trying to do with this data in order to answer properly, but anyway, I would try one of these two solutions:
1 - Inline formula:
Using only native functions has a better performance. Not sure how you could achieve this, since you are iterating inside that TAGS function.
2- Calculate values interely with Script and replace values in column E:
You could create a function that may run from onEdit event or get activated by a custom menu. Generally it would be like this:
function calculateColumnE() {
var sheet = SpreadsheetApp.openById('some-id').getSheetByName('some-name');
var row_count = sheet.getLastRow();
var input_data = sheet.getRange(1, 1, row_count, 4).getValues();
var data = [];
for (var i = 0; i < row_count; i++) {
var row_data; // this variable will receive value for column E in this row
/*
...
manage input_data here
...
*/
data.push([row_data]); // data array MUST be a 2 dimensional array
}
sheet.getRange(1, 5, data.length, 1).setValues(data);
}
EDIT
Here is the full code for solution 2:
function TAGS(input,textreplacement) { //keeping your original function
if (input.length > 0) {
var lst = input.split(",")
var rep = textreplacement.match(/<[^>]*>/g)
for (i in rep){
textreplacement = textreplacement.replace(rep[i],'"'+lst[i]+'"')
}
return textreplacement
}
else{
return textreplacement
}
}
function calculateColumnE() {
var sheet = SpreadsheetApp.openById('some-id').getSheetByName('some-name');
var row_count = sheet.getLastRow();
var input_data = sheet.getRange(1, 1, row_count, 4).getValues();
var data = [];
for (var i = 0; i < row_count; i++) {
var row_data; // this variable will receive value for column E in this row
if (input_data[i][0] == "Spec") {
row_data = "# SPEC " + input_data[i][1];
} else if (input_data[i][0] == "Scenario") {
row_data = "## " + input_data[i][1];
} else if (input_data[i][0] == "Step") {
row_data = "* " + TAGS(input_data[i][2], input_data[i][3]);
} else if (input_data[i][0] == "Tag") {
row_data = "Tags: " + input_data[i][1].replace(/\s/, ''); // not sure what this is doing
} else if (input_data[i][0] == "") {
row_data = "";
}
data.push([row_data]); // data array MUST be a 2 dimensional array
}
sheet.getRange(1, 5, data.length, 1).setValues(data);
}
I also created a working example, which you can check here: https://docs.google.com/spreadsheets/d/1q2SYD7nYubSuvkMOKQAFuGsrGzrMElzZNIFb8PjM7Yk/edit#gid=0 (send me request if you need it).
It works like a charm using onEdit event to trigger calculateColumnE() with few lines, I'm curious to know about the result in your 1000+ rows sheet. If it get slow, you may need to run this function manually.
Not sure if this will be faster:
function TAGS(input,tr) {
if (input.length > 0) {
var lst = input.split(",");
var i=0;
tr=tr.replace(/<[^>]*>/g,function(){return '"' + lst[i++] + '"';});
}
return tr;
}

Speed up changes from script to scattered cells in google sheets

I have a sheet that shows the results of calculations based on other backing sheets. Changes are made manually, but the "summary" sheet is only formulae. These changes appear in scattered cells, non-contiguous for the most part.
I want to highlight which cells have changed in the summary sheet after a manual change in the backing sheets. For that, I'm using a second summary sheet which starts as a copy of the main one.
The final ingredient is a script that runs after edits. It traverses the summary range and compares values to the second copy. Any differences get highlighted in the main summary and copied back to the second summary.
This process does work but is quite slow, I think due to the updates. Pseudo-code:
var src = summary.getRange(...)
var dst = copy.getRange(...)
var src_cell;
var dst_cell;
src.setBackground('white'); // Bulk reset of changes
for (row = 1; row < src.getNumRows(); row++) {
for (col = 1; col < src.getNumColumns(); col++) {
src_cell = src.getCell(row, col);
dst_cell = src.getCell(row, col);
if (src_cell.getDisplayValue() != dst_cell.getDisplayValue()) {
dst_cell.setValue(src_cell.getDisplayValue());
src_cell.setBackground('gray');
}
}
}
I think there is no way to bulk-update scattered ranges, which seems a straightforward solution.
I'm looking for ways to speed up this process, either in the script or by using some other strategy.
Per official "best practices," you should batch-read associated cell data rather than repeatedly read and possibly write values. This statement does assume that setting values in dst does not influence values for future reads.
Thus, the simplest change is to use Range#getDisplayValues on src and dst:
...
src.setBackground("white");
var srcValues = src.getDisplayValues();
var dstValues = dst.getDisplayValues();
srcValues.forEach(function (srcRow, r) {
var dstRow = dstValues[r];
srcRow.forEach(function (value, c) {
if (value !== dstRow[c]) {
dst.getCell(r + 1, c + 1).setValue(value);
src.getCell(r + 1, c + 1).setBackground("gray");
}
});
});
An additional optimization is to use the RangeList class to batch the changes. To create a RangeList, you need an array of cell / range notations, which can use R1C1- or A1-style addressing. R1C1 is simplest to compute.
...
var dstChanges = [];
var srcChanges = [];
...
if (value !== dstRow[c]) {
dstChanges.push({row: r + 1, col: c + 1, newValue: value});
srcChanges.push({row: r + 1, col: c + 1});
}
...
if (srcChanges.length > 0) {
var srcRow = src.getRow();
var dstRow = dst.getRow();
var srcCol = src.getColumn();
var dstCol = dst.getColumn();
copy.getRangeList(dstChanges.map(function (obj) {
return "R" + (obj.row + dstRow) + "C" + (obj.col + dstCol);
}).getRanges().forEach(function (rg, i) {
rg.setValue(dstChanges[i].newValue);
});
summary.getRangeList(srcChanges.map(function (obj) {
return "R" + (obj.row + srcRow) + "C" + (obj.col + srcCol);
}).setBackground("gray");
}
...
Other Refs
Array#forEach
Array#map
Array#push

Send email if cells' value is below certain value

I am absolutely new to this and pulling my hair out trying to make a script for work.
I need to check employee's certifications on a daily basis and have them re-certified if expired.
Here is a "testing" spreadsheet with random data: https://docs.google.com/spreadsheets/d/1vJ8ms5ZLqmnv4N1upNHD4SRfgIgIbEAAndvUNy-s9S4/edit?usp=sharing
It lists personnel working for my department along with their badge numbers and number of days their certifications are valid for. The original sheet takes the days value from another spreadsheet, but it shouldn't affect this (I think?).
What I'm trying to achieve is write a script that checks all numbers in C3:G24.
If any cell in this range has a value lower than 15, it should pull their badge number and name from the same row, along with the "days" their certificates are valid for and send an email containing all this data.
For example
Subject: Certifications about to expire
E-mail content: Your employee's (Name from col B) certification with Badge# (# from Col A) will expire in X days (where X = the number from range C3:G24).
So far my best attempt was to at least make it send ANY e-mail on edit, but failing miserably trying to adapt any script found online.
Here is what worked to at least send an e-mail but then I did something to break it:
function checkValue()
{
var ss = SpreadsheetApp.getActive();//not sure if needed, the spreadsheet eventually doesn't have to be open/active
var sheet = ss.getSheetByName("Certifications");
//not sure if this is ok
var valueToCheck = sheet.getRange("C3:G24").getValue();
//Here I'd like to change the "days" based on needs
if(valueToCheck < 15)
{
MailApp.sendEmail("email#company.com","Certifications","Your employee certification will expire soon" + valueToCheck+ ".");
}
}
Can someone please help guide me in the right direction?
here is what I would do:
function checkValue()
{
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName("Certifications");
var valueToCheck = sheet.getDataRange().getValues();
var resultValues = [];
valueToCheck = valueToCheck.filter(function(element){
var val = 0
if (parseInt(element[2]) < 15)
{
resultValues.push({col: "Cert1", value: element[2]})
return (true);
}
else if (parseInt(element[3]) < 15)
{
resultValues.push({col: "Cert2", value: element[3]})
return (true);
}
else if (parseInt(element[4]) < 15)
{
resultValues.push({col: "Cert3", value: element[4]})
return (true);
}
else if (parseInt(element[5]) < 15)
{
resultValues.push({col: "Cert4", value: element[5]})
return (true);
}
else if (parseInt(element[6]) < 15)
{
resultValues.push({col: "Cert5", value: element[6]})
return (true);
}
})
for(var i = 0; i < valueToCheck.length; i++)
{
MailApp.sendEmail("mail#company.com","Certifications","your employee's " + valueToCheck[i][1] + "with badge " + valueToCheck[i][0] + " certification will expire in " + resultValues[i].value + " days (column " + resultValues[i].col + ").");
}
}
use the getValues() function to retrieve datas.
then filter the values based on condtion of value being less than 15
at the same time grab the column name and the less than 15 data.
parse through both arrays to send datas to your mail