Excel Course
Advanced Scripts Project
This project brings together everything from Section VII: Office Scripts basics, recording and cleaning, writing scripts with loops and conditionals, helper functions, the Excel Table API, error handling, and Power Automate integration. You will build a complete automated order processing pipeline — a script that reads a raw orders table, validates each row, flags exceptions, writes a summary, and returns structured data that a Power Automate flow uses to send a conditional alert email. By the end of this lesson you have a production-ready automation template you can adapt for real work.
Project Overview — What We Are Building
The Source Data
The project uses an Excel Table named "Orders" on a sheet named "Data". Seven columns. The script reads this table, validates each row, and adds two new columns — Flag and Reason — if they do not already exist.
| OrderID | Date | CustomerID | Product | Revenue | Status | Flag |
|---|---|---|---|---|---|---|
| 1001 | 2025-02-01 | C011 | Laptop Pro | £1,450 | Paid | — |
| 1002 | 2025-01-15 | C034 | Desk Chair | £320 | Overdue | ⚠ REVIEW |
| 1003 | 2025-02-10 | (blank) | Monitor 4K | £890 | Paid | ⚠ DATA |
| 1004 | 2025-01-20 | C019 | Keyboard | -£75 | Paid | ⚠ VALUE |
| 1005 | 2025-02-18 | C052 | Webcam HD | £145 | Paid | — |
Phase 1 — Validation and Flagging Script
function main(workbook: ExcelScript.Workbook): string {
// ── Phase 1: Validation & Flagging ─────────────────────────────
const dataSheet = workbook.getWorksheet("Data");
if (!dataSheet) {
return JSON.stringify({ error: "Sheet 'Data' not found." });
}
const table = dataSheet.getTable("Orders");
if (!table) {
return JSON.stringify({ error: "Table 'Orders' not found." });
}
// Ensure Flag and Reason columns exist — add them if not
let flagCol = table.getColumnByName("Flag");
let reasonCol = table.getColumnByName("Reason");
if (!flagCol) { table.addColumn(-1, "Flag"); }
if (!reasonCol) { table.addColumn(-1, "Reason"); }
// Re-fetch after potential additions
flagCol = table.getColumnByName("Flag");
reasonCol = table.getColumnByName("Reason");
const body = table.getRangeBetweenHeaderAndTotal();
const rowCount = body.getRowCount();
const allValues = body.getValues();
// Column index map (0-based within the table body)
const COL = {
orderID: 0,
date: 1,
customerID: 2,
product: 3,
revenue: 4,
status: 5
};
let flaggedCount = 0;
let totalRevenue = 0;
for (let i = 0; i < rowCount; i++) {
const customerID = allValues[i][COL.customerID] as string;
const revenue = allValues[i][COL.revenue] as number;
const status = allValues[i][COL.status] as string;
const rowRange = body.getRow(i);
const flagCell = flagCol.getRangeBetweenHeaderAndTotal().getRow(i);
const reasonCell = reasonCol.getRangeBetweenHeaderAndTotal().getRow(i);
let flag = "";
let reason = "";
// Validation rule 1: missing CustomerID
if (!customerID || customerID.toString().trim() === "") {
flag = "DATA";
reason = "Missing CustomerID";
rowRange.getFormat().getFill().setColor("#fef9c3");
}
// Validation rule 2: negative revenue
else if (revenue < 0) {
flag = "VALUE";
reason = "Negative revenue: £" + revenue;
rowRange.getFormat().getFill().setColor("#fef2f2");
}
// Validation rule 3: overdue status
else if (status === "Overdue") {
flag = "REVIEW";
reason = "Payment overdue";
rowRange.getFormat().getFill().setColor("#fef2f2");
}
// Clean row — clear any previous flag
else {
rowRange.getFormat().getFill().clear();
}
flagCell.setValue(flag);
reasonCell.setValue(reason);
if (flag !== "") flaggedCount++;
if (revenue > 0) totalRevenue += revenue;
}
console.log("Phase 1 complete: " + flaggedCount + " rows flagged.");
Notice the column index map at the top of the loop — the COL object maps readable names to numeric indexes. This is far better than writing allValues[i][2] throughout the loop. When a column is added or reordered, you update the COL map once rather than hunting through every reference in the code.
Phase 2 — Writing the Summary Sheet
// ── Phase 2: Summary Sheet ──────────────────────────────────────
// Get or create the Summary sheet
let summarySheet = workbook.getWorksheet("Summary");
if (!summarySheet) {
summarySheet = workbook.addWorksheet("Summary");
}
// Clear previous content
summarySheet.getUsedRange()?.clear();
const today = new Date().toISOString().split("T")[0]; // "2025-03-10"
// Write summary data
const summaryData: (string | number)[][] = [
["Order Processing Summary", ""],
["Run date", today],
["Total orders", rowCount],
["Flagged orders", flaggedCount],
["Clean orders", rowCount - flaggedCount],
["Total revenue (clean)", totalRevenue],
["Flag rate", flaggedCount / rowCount]
];
summarySheet.getRangeByIndexes(0, 0, summaryData.length, 2)
.setValues(summaryData);
// Format the header row
const headerRange = summarySheet.getRangeByIndexes(0, 0, 1, 2);
headerRange.getFormat().getFill().setColor("#0f172a");
headerRange.getFormat().getFont().setColor("#ffffff");
headerRange.getFormat().getFont().setBold(true);
headerRange.getFormat().setRowHeight(28);
headerRange.merge(false); // merge A1:B1 for the title
// Format the flag rate row as percentage
summarySheet.getRangeByIndexes(6, 1, 1, 1)
.setNumberFormatLocal("0.0%");
// Format the revenue row as currency
summarySheet.getRangeByIndexes(5, 1, 1, 1)
.setNumberFormatLocal("£#,##0");
// Auto-fit columns
summarySheet.getUsedRange().getFormat().autofitColumns();
console.log("Phase 2 complete: Summary sheet updated.");
| Order Processing Summary | |
| Run date | 2025-03-10 |
| Total orders | 48 |
| Flagged orders | 9 |
| Clean orders | 39 |
| Total revenue (clean) | £84,320 |
| Flag rate | 18.8% |
Phase 3 — Return JSON to Power Automate
// ── Phase 3: Return structured result ───────────────────────────
const result = {
totalRows: rowCount,
flaggedCount: flaggedCount,
cleanCount: rowCount - flaggedCount,
totalRevenue: totalRevenue,
flagRate: Math.round((flaggedCount / rowCount) * 1000) / 10, // e.g. 18.8
runDate: today,
status: flaggedCount > 0 ? "FLAGGED" : "CLEAN"
};
console.log("Phase 3 complete. Status: " + result.status);
return JSON.stringify(result);
} // end of main function
The Power Automate Flow
Flow structure:
TRIGGER
Recurrence — every weekday at 07:00
STEP 1 — Excel Online (Business): Run script
File: /Reports/Orders.xlsx
Script: Process Orders
STEP 2 — Parse JSON
Content: outputs('Run_script')?['body/result']
Schema: {
"type": "object",
"properties": {
"totalRows": { "type": "integer" },
"flaggedCount": { "type": "integer" },
"cleanCount": { "type": "integer" },
"totalRevenue": { "type": "number" },
"flagRate": { "type": "number" },
"runDate": { "type": "string" },
"status": { "type": "string" }
}
}
STEP 3 — Condition
If: body('Parse_JSON')?['status'] is equal to "FLAGGED"
YES path — Send an email (V2):
To: operations@company.com
Subject: ⚠️ Order Review Required — [runDate]
Body: [flaggedCount] orders flagged out of [totalRows] total.
Flag rate: [flagRate]% | Clean revenue: £[totalRevenue]
Open the workbook for details: [link to file]
NO path — Terminate (status: Succeeded)
Project QA Checklist
Before going live, verify each of the following:
Script tests:
✓ Script runs without errors on the real workbook
✓ Flag column correctly identifies DATA, VALUE, and REVIEW cases
✓ Clean rows have no flag and cleared fill colour
✓ Summary sheet totals match a manual spot check
✓ Returned JSON string is valid (paste into jsonlint.com to verify)
✓ Script handles empty table gracefully (rowCount = 0, no loop errors)
Power Automate tests:
✓ Flow runs manually (Test → Manually) without errors
✓ Parse JSON step successfully extracts all fields
✓ Condition correctly branches on "FLAGGED" vs "CLEAN"
✓ Alert email arrives with correct values in body (test with a flagged row)
✓ Silent run (no flagged rows) produces no email — Terminate fires correctly
✓ Flow run history shows "Succeeded" for both test cases
🠐 Practice
Q1. In Phase 1, the script checks if the Flag and Reason columns exist in the table and adds them if not. Why is this check important rather than assuming the columns are already there?
Q2. The COL object maps column names to numeric indexes. What is the advantage of this pattern over writing allValues[i][2] directly in the loop?
Q3. In the Power Automate flow, why does the NO path of the Condition step terminate rather than doing nothing?
🟣 Quiz
Q1. The script uses summarySheet.getUsedRange()?.clear() with a question mark before .clear(). Why is the ?. (optional chaining) operator used here?
Q2. The script returns JSON.stringify(result) as a string. In Power Automate, what must you do before you can use individual fields like flaggedCount or runDate as dynamic content tokens?
Q3. The validation loop clears the row fill colour on clean rows: rowRange.getFormat().getFill().clear(). Why is this step important?
Next up — Section VIII: Final Projects, where you apply everything learned across the entire course — Power Query, Power Pivot, DAX, dashboards, and Office Scripts — to build a complete, professional-grade Excel solution from scratch.