Table of Contents
- 1 The Business Situation
- 2 The Existing Process
- 2.1 Operational weaknesses
- 2.2 Practical effects
- 3 What the New System Needed to Do
- 4 Implementation Approaches Considered
- 4.1 Improved spreadsheets
- 4.2 AppSheet with Google Workspace
- 4.3 Airtable
- 4.4 Project platform and BI tool
- 4.5 Custom application
- 5 The Selected Solution
- 6 System Architecture and Data Flow
- 7 Data Structure
- 8 Workflow Statuses and Ownership
- 9 Step-by-Step Implementation
- 9.1 Step 1: Prepare the Accounts and Permissions
- 9.2 Step 2: Build the Intake
- 9.3 Step 3: Create the System of Record
- 9.4 Step 4: Connect the Tools
- 9.5 Step 5: Build the Core Automation
- 9.6 Step 6: Add Approvals, Reminders, and Escalations
- 9.7 Step 7: Add Documents and File Management
- 9.8 Step 8: Add Reporting and Operational Views
- 9.9 Step 9: Add Security and Governance Controls
- 9.10 Step 10: Deploy and Test
- 10 Code and Configuration
- 10.1 Configuration and Deployment
- 11 Failure Handling and Operational Reliability
- 12 A Complete Example
- 13 Implementation Cost
- 14 Estimated Time and Cost Savings
- 15 Adding AI to the Automation
- 15.1 The Recommended AI Enhancement
- 15.2 Benefits of the AI Enhancement
- 15.3 What Remains Rule-Based or Human-Controlled
- 15.4 Estimating the Additional Value of AI
- 16 Testing Checklist
- 17 Ongoing Maintenance
- 18 When to Move to Dedicated Software
- 19 Implementation Checklist
The Business Situation
Cedar Vale Industrial Projects is a fictional 86-person business that designs and manufactures packaging and material-handling equipment. Its workforce includes mechanical engineers, electrical engineers, programmers, fabricators, installers, and project managers.
The company typically manages 24 active or scheduled customer projects, seven project managers, 58 employees who can be allocated to project work, and approximately 145 current or future named allocations. About 220 requirement, allocation, or availability changes are processed each month.
The operations planning manager is responsible for balancing project demand against employee capacity. Project managers define required roles and dates, department supervisors nominate employees, and an HR coordinator records approved leave. The operations director reviews material conflicts and approves exceptions.
The business uses Google Workspace, but its planning process is divided among project spreadsheets, a shared leave calendar, email, and informal supervisor notes. Project managers can see their own schedules, but nobody has a dependable combined view of demand, skills, planned leave, named allocations, and unresolved capacity conflicts.
The immediate requirement is not a full enterprise resource management system. It is a controlled planning register that can support mobile changes, calculate weekly capacity, identify exceptions, preserve human approval, and show scenarios without altering the approved baseline plan.
Note: This case study is provided as a representative example of the types of AI integration and digital transformation solutions Intelligex designs and delivers. Actual engagements are tailored to each client’s goals, constraints, existing systems, timeline, and available resources, so the approach, tools, and outcomes may vary.
The Existing Process
The original process runs in the following order:
- A project manager adds role requirements to an individual project spreadsheet.
- The project manager emails department supervisors to ask who might be available.
- Supervisors check their own team schedules, remembered commitments, and the shared leave calendar.
- Suggested names are returned by email or entered into another spreadsheet.
- The operations planning manager copies allocations into a consolidated workbook.
- The HR coordinator sends approved leave changes separately.
- The planning manager manually adjusts capacity calculations and highlights apparent conflicts.
- Project managers review the workbook during a weekly planning meeting.
- Decisions made during the meeting are copied back into project schedules and follow-up emails.
Operational weaknesses
- Project dates, leave, skills, and allocations are maintained separately.
- The same employee and project information is entered more than once.
- Spreadsheet names, role names, and status values are inconsistent.
- Changes made after the weekly meeting can remain invisible for several days.
- Email threads do not provide a reliable approval history.
- Scenario changes can be confused with approved baseline allocations.
Practical effects
- Overallocations are found late, often during project delivery.
- Project managers reserve the same specialist without seeing competing demand.
- Approved leave can remain in conflict with project allocations.
- Skill gaps are discussed but cannot be reported consistently.
- The planning manager becomes a dependency for reconciliation.
- Leadership cannot distinguish confirmed shortages from incomplete data.
The central problem is not simply that spreadsheets are being used. The problem is that separate spreadsheets have no governed data model, reliable synchronization, ownership rules, automated exception handling, or protected scenario structure.
What the New System Needed to Do
| Requirement | Required behavior |
|---|---|
| Mobile intake | Allow authorized managers to add or update requirements, allocations, and availability from a phone or browser. |
| Structured records | Use controlled project, employee, role, skill, status, and scenario values. |
| Unique identifiers | Assign stable IDs that do not change when rows are sorted or moved. |
| Capacity calculation | Calculate weekly employee capacity after approved leave and named allocations. |
| Demand calculation | Expand project requirements into weekly demand and compare required hours with assigned hours. |
| Conflict detection | Identify overallocations, leave overlaps, unfilled requirements, skill mismatches, invalid dates, and duplicate records. |
| Scenario planning | Keep baseline and proposed allocations separate so planners can compare options safely. |
| Ownership | Assign every exception to a project manager, employee manager, or central planning owner. |
| Approvals | Keep requirement approval, allocation confirmation, rejection, and override decisions under human control. |
| Notifications | Send concise email alerts for new material exceptions without creating repeated messages for the same conflict. |
| Exception queue | Provide open, acknowledged, overridden, and resolved exception statuses. |
| Reporting | Show capacity, demand, utilization, project gaps, upcoming leave, unresolved exceptions, and automation failures. |
| Audit evidence | Record timestamps, approvers, automation runs, event IDs, errors, and resolution notes. |
| Recovery | Retry transient failures and provide a manual route for reprocessing failed records. |
| Security | Limit direct spreadsheet access and apply role-based controls in the mobile application. |
| Optional AI | Summarize validated conflicts for planners without allowing AI to approve allocations or modify capacity calculations. |
Implementation Approaches Considered
| Approach | Connected tools | Effort | Strengths | Limitations |
|---|---|---|---|---|
| Improve the existing spreadsheets | Google Sheets and Gmail | Low | Familiar and inexpensive to start | Weak mobile entry, permissions, validation, workflow, and audit control |
| AppSheet planning application | AppSheet, Google Sheets, Apps Script, Gmail, and Google Drive | Moderate | Mobile forms, governed data, custom calculations, alerts, and scenario views | Requires disciplined data design and ongoing script ownership |
| No-code operational database | Airtable, email, and optional automation services | Moderate | Strong relational views and accessible configuration | Adds another platform and may duplicate existing Google Workspace administration |
| Project tool with BI reporting | Project-management platform and a BI tool | Moderate to high | Useful when detailed task schedules already contain dependable resource data | Leave, proficiency, scenario modeling, and exception workflows may still require extensions |
| Custom planning application | Web application, database, APIs, identity provider, and BI platform | High | Maximum control, scale, and workflow customization | Higher implementation, security, hosting, testing, and support burden |
Improved spreadsheets
A single protected workbook would reduce some reconciliation. It would not provide sufficiently controlled mobile forms, row-level experiences, role-sensitive actions, or dependable event processing. It also would leave managers editing operational tables directly.
AppSheet with Google Workspace
This option retained the existing Google environment while adding mobile forms and governed views. Google Apps Script could perform calculations that were too interconnected for simple spreadsheet formulas, and Gmail could send controlled exception summaries.
Airtable
Airtable was a credible alternative because the planning data is relational. It was not selected for this representative scenario because the business wanted to retain Google Sheets as the operational register and avoid introducing another core data administration platform.
Project platform and BI tool
This would be suitable if task-level schedules were already standardized and maintained accurately. Cedar Vale’s project files did not yet contain consistent role, skill, leave, or named-capacity information, so a reporting layer would have exposed the same data-quality gaps.
Custom application
A custom application would be appropriate at substantially higher scale or with complex scheduling algorithms. It was disproportionate for 58 allocatable employees, 24 projects, and a planning horizon of 16 weeks.
The Selected Solution
The selected implementation uses AppSheet as the controlled mobile interface, Google Sheets as the system of record, Google Apps Script as the calculation and workflow layer, Gmail for notifications, and Google Drive for the workbook and limited supporting documents.
| Tool | Responsibility |
|---|---|
| AppSheet | Mobile forms, reference selection, validation, role-based views, approval actions, scenario dashboards, and webhook initiation |
| Google Sheets | Projects, employees, skills, requirements, allocations, leave, weekly calculations, exceptions, events, settings, and AI briefs |
| Google Apps Script | Validation, weekly expansion, capacity calculations, conflict detection, idempotency, retries, reminders, logging, and optional AI calls |
| Gmail | New-exception alerts, overdue reminders, escalation messages, and automation-failure notices |
| Google Drive | Controlled storage for the workbook, exports, planning documentation, and optional skill-evidence files |
| AppSheet and Sheets views | Operational reporting, exception queues, scenario comparison, utilization analysis, and automation monitoring |
| Optional AI API | Creation of a reviewable conflict briefing from validated exception records |
Existing Google accounts and Gmail were retained. Direct editing of operational tabs was removed for ordinary users. Project managers and supervisors entered changes through AppSheet, while the operations planning manager retained controlled spreadsheet access for administration and recovery.
Human control remained over requirement approval, named allocation confirmation, high-severity overrides, skill acceptance, leave approval, and project priority. The automation calculated consequences and routed exceptions, but it did not make staffing decisions.
System Architecture and Data Flow
- Mobile entry: A project manager, supervisor, or HR coordinator creates or updates a record in AppSheet.
- App validation: AppSheet checks required fields, allowed values, dates, references, and user permissions before synchronization.
- Register update: AppSheet writes the validated row to the appropriate Google Sheets table and marks its automation status as Pending.
- Event initiation: An AppSheet automation bot sends a webhook containing an event ID, entity type, record ID, event type, and shared secret to an Apps Script web application.
- Idempotency check: Apps Script checks the Events table. A completed event with the same event ID is acknowledged without repeating the calculation.
- Planning rebuild: Apps Script expands requirements and allocations across the configured weekly horizon, subtracts approved leave, and calculates remaining capacity.
- Exception generation: The script creates deterministic exception IDs for overallocations, leave overlaps, skill mismatches, unfilled requirements, invalid dates, and duplicate entries.
- Record update: The source row is marked Processed, output tables are refreshed, and the event receives a Completed status.
- Notification: New high- and medium-severity exceptions are grouped by owner and sent through Gmail. Previously notified exceptions are not sent again unless they are resolved and later recur.
- Fallback processing: A five-minute time trigger scans for Pending rows. This recovers changes when a webhook is unavailable or delayed.
- Reporting: AppSheet dashboards and protected Sheets views read the calculated capacity, demand, and exception tables.
- Failure path: A failed event is logged, the source row receives an Error status, the planning owner is notified, and an authorized administrator can reprocess the record.
- Intake: AppSheet forms for projects, requirements, allocations, skills, and approved availability changes
- System of record: Structured Google Sheets tables
- Automation layer: Google Apps Script web app plus scheduled fallback triggers
- Document storage: Restricted Google Drive folders
- Notifications: Gmail messages generated by Apps Script
- Reporting: AppSheet dashboards, slices, charts, and protected Sheets analysis views
- AI layer: Optional structured conflict briefing created from open exception data
Data Structure
The workbook uses separate tabs as logical tables. IDs, rather than row numbers, connect the records. A project can have multiple requirements and allocations. An employee can have multiple skills, allocations, and leave records. Requirements and allocations belong to a scenario, while employee capacity and approved leave are shared across scenarios.
| Table and field | Type | Required | Source | Purpose and validation |
|---|---|---|---|---|
| Projects.ProjectID | Text key | Yes | AppSheet | Stable ID generated with UNIQUEID(); never reused |
| Projects.StartDate and EndDate | Date | Yes | Project manager | End date must be on or after start date |
| Projects.ProjectManagerEmail | Yes | Users reference | Defines normal exception ownership | |
| Employees.EmployeeID | Text key | Yes | Administrator | Stable workforce reference, separate from email |
| Employees.WeeklyCapacityHours | Decimal | Yes | Operations or HR | Must be greater than zero and normally no more than the policy maximum |
| EmployeeSkills.Proficiency | Integer | Yes | Supervisor | Controlled scale from 1 to 5 |
| Requirements.RequirementID | Text key | Yes | AppSheet | Links weekly demand to named allocations |
| Requirements.SkillID | Reference | Conditional | Skills table | Required when the role needs a verified skill |
| Requirements.HoursPerWeek | Decimal | Yes | Project manager | Expected weekly demand, prorated for partial weeks |
| Allocations.AllocationID | Text key | Yes | AppSheet | Stable named-allocation identifier |
| Allocations.RequirementID | Reference | Yes | Requirements table | Connects the employee assignment to demand |
| Allocations.EmployeeID | Reference | Yes | Employees table | Must reference an active employee |
| Allocations.Status | Enum | Yes | Workflow | Draft, Proposed, Confirmed, Rejected, or Cancelled |
| Allocations.ApprovedBy and ApprovedAt | Email and timestamp | Conditional | Approval action | Required when an allocation becomes Confirmed |
| Leave.ApprovalStatus | Enum | Yes | HR coordinator | Only Approved leave reduces available capacity |
| CapacityWeekly.CapacityKey | Text key | Yes | Apps Script | Combination of scenario, employee, and week |
| CapacityWeekly.UtilizationPct | Decimal | No | Apps Script | Allocated hours divided by available hours |
| DemandWeekly.GapHours | Decimal | Yes | Apps Script | Required hours minus named allocated hours, with a minimum of zero |
| Exceptions.ExceptionID | Text key | Yes | Apps Script | Deterministic key prevents duplicate exceptions for the same condition |
| Exceptions.Status | Enum | Yes | Automation and owner | Open, Acknowledged, Override Approved, or Resolved |
| Exceptions.Error context | Text | No | Apps Script | Description uses project and employee IDs, dates, and hours rather than sensitive leave details |
| Events.EventID | Text key | Yes | Webhook | Idempotency key for duplicate delivery protection |
| Events.AttemptCount | Integer | Yes | Apps Script | Tracks repeated processing attempts |
| AutomationStatus | Enum | Yes | AppSheet and Apps Script | Pending, Processed, or Error |
| LastAutomationRun | Timestamp | No | Apps Script | Shows when the source row last completed processing |
| ErrorMessage | Long text | No | Apps Script | Contains a concise processing error for manual recovery |
The scenario identifier uses controlled values such as Baseline, Option-A, and Option-B. A scenario is created by copying relevant baseline requirements and allocations with new IDs and a new ScenarioID. The approved baseline is never edited merely to test a possible staffing change.
The system does not store sensitive reasons for leave. The planning calculation only needs employee ID, dates, hours per day, and approval status.
Workflow Statuses and Ownership
| Status | Meaning | Owner | Entry and exit conditions | Reminder or escalation |
|---|---|---|---|---|
| Draft | Requirement or allocation is incomplete | Creator | Entered during preparation; exits when submitted as Proposed | No central alert |
| Proposed | Record is included in scenario calculations but is not confirmed | Project manager | Enters after validation; exits through confirmation, rejection, or return | Reminder after two business days |
| Confirmed | Named allocation is approved for the scenario | Operations planning manager | Requires approver identity, timestamp, and no unresolved blocking conflict unless an override is approved | New conflicts reopen review |
| Rejected | Proposed record will not proceed | Original requester | Requires rejection reason; may be copied into a new corrected proposal | No reminder unless resubmitted |
| Cancelled | Previously planned work is no longer active | Project manager | Soft deletion preserves history and removes hours from calculation | No reminder |
| Open exception | Conflict currently exists | Assigned manager | Created or reopened by calculation; exits through resolution or approved override | Email notification and daily aging review |
| Acknowledged exception | Owner has accepted responsibility for investigation | Assigned manager | Set by owner; returns to Open if materially recreated after resolution | Escalates when overdue |
| Override Approved | Authorized manager accepts a documented conflict | Operations director | Requires reason and approver; closes when the underlying condition disappears | Included in audit reporting |
| Resolved exception | The calculation no longer detects the condition | Automation | Entered automatically after recalculation | No reminder |
| Automation Error | A source change did not complete processing | Planning system owner | Entered after script failure; exits after successful reprocessing | Immediate system-owner alert |
High-severity conflicts do not automatically cancel or reassign work. The planning manager investigates and either changes dates, hours, or employees; returns the proposal; or requests a documented override from the operations director.
Step-by-Step Implementation
Step 1: Prepare the Accounts and Permissions
- Create or identify a dedicated Google Workspace automation account. It owns the workbook, Apps Script project, web-app deployment, and Gmail notifications.
- Confirm that the AppSheet subscription and administrative policy support the required number of authenticated users, automation bots, webhook tasks, and deployed applications. Product entitlements can change, so verify current licensing rather than relying on an assumed plan name.
- Create a restricted shared-drive or Drive folder named
Resource Planning. Add subfolders forSystem,Exports,Skill Evidence, andDocumentation. - Create the Google Sheets workbook in the System folder. Only the automation owner, planning system owner, and backup owner receive direct edit access.
- Create a shared planning address or Google Group for system alerts. The code uses a configured planner address rather than embedding a personal mailbox.
- Define AppSheet roles in the Users table: Admin, Planner, ProjectManager, Supervisor, HR, Executive, and Employee.
- Create at least one test user for each role. Use non-production sample projects and employee IDs during testing.
- Decide whether the Apps Script web app may be accessible to AppSheet’s server-side webhook. If organizational policy prohibits a token-protected public endpoint, disable the webhook and use the five-minute scheduled scan as the primary trigger.
- Store secrets in Apps Script Properties, not in spreadsheet cells or AppSheet user-visible fields.
The Apps Script deployment executes as the automation owner. That account therefore needs access to the workbook and permission to send Gmail messages. Ordinary AppSheet users do not need direct access to the underlying spreadsheet.
Permission boundary: AppSheet slices and views improve the user experience, but they are not substitutes for security filters. Apply security filters and application authorization before adding convenience views.
Step 2: Build the Intake
Create AppSheet tables from the Projects, Employees, Skills, EmployeeSkills, Requirements, Allocations, Leave, Exceptions, Users, CapacityWeekly, and DemandWeekly tabs. Keep Events, Settings, and AI_Briefs restricted to administrators unless a controlled monitoring view is required.
| Form | Required fields | Key validation |
|---|---|---|
| Project | Name, dates, manager, priority, status, scenario | End date cannot precede start date; manager must be an active authorized user |
| Requirement | Project, role, dates, hours per week, scenario, priority | Dates must normally fall within the project; hours must be positive |
| Allocation | Requirement, employee, dates, hours per week, scenario, status | Employee must be active; requirement and scenario must agree; dates must be valid |
| Employee skill | Employee, skill, proficiency | Proficiency is an integer from 1 to 5; duplicate employee-skill pairs are blocked |
| Leave | Employee, start date, end date, hours per day, approval status | End date cannot precede start date; hours must be within policy limits |
Use UNIQUEID() as the initial value for each key column. Use reference columns for projects, employees, requirements, skills, and users instead of free-text entry.
Configure enum values rather than allowing arbitrary status text. Use conditional display rules so rejection reasons appear only when Rejected is selected and override reasons appear only when an override is requested or approved.
For exact duplicate prevention, an allocation can use a validation expression based on employee, requirement, dates, scenario, and active status. Do not block all overlapping allocations because legitimate employees may divide a week among several projects. The capacity engine should detect excessive total hours instead.
COUNT(
SELECT(
Allocations[AllocationID],
AND(
[EmployeeID] = [_THISROW].[EmployeeID],
[RequirementID] = [_THISROW].[RequirementID],
[StartDate] = [_THISROW].[StartDate],
[EndDate] = [_THISROW].[EndDate],
[ScenarioID] = [_THISROW].[ScenarioID],
NOT(IN([Status], {"Rejected", "Cancelled"})),
[AllocationID] <> [_THISROW].[AllocationID]
)
)
) = 0
Add a short privacy notice to the leave form. Explain that the planning application requires dates and hours only and must not contain medical information or detailed absence reasons.
Set the confirmation message to tell users that the record has been saved and that capacity calculations may take several minutes if the fallback scheduled process is used.
Hard deletion should be disabled for normal users. Status changes to Cancelled preserve history and make webhook recovery possible.
Step 3: Create the System of Record
Run the supplied setupWorkbook function in the bound Apps Script project. It creates the required tabs and exact headers without overwriting existing populated tables.
Use the following naming rules:
- Project IDs: AppSheet-generated text keys such as
PRJ-7F21A9or rawUNIQUEID()values - Employee IDs: stable internal identifiers such as
EMP-1042 - Requirement IDs: generated text keys
- Allocation IDs: generated text keys
- Scenario IDs: controlled values such as Baseline, Option-A, and Option-B
- Capacity keys: scenario, employee, and Monday week date combined by the script
- Exception IDs: SHA-256-derived deterministic values generated by the script
The output tabs, CapacityWeekly and DemandWeekly, are rebuilt by the automation. Users must not edit them directly. Exceptions preserves workflow status and resolution notes while refreshing calculated details.
Create filtered AppSheet slices for Baseline, each proposed scenario, open exceptions, automation errors, and the current 16-week horizon. Use Monday as the standard week start and confirm that the spreadsheet and Apps Script project use the same organizational time zone.
The Settings table contains configurable values such as planning email, horizon weeks, reminder age, and severities that require immediate notification. Keep this table administrator-only.
Step 4: Connect the Tools
| Source | Destination | Trigger and authentication | Mapping and returned data |
|---|---|---|---|
| AppSheet allocation form | Google Sheets Allocations | Authenticated app synchronization | Allocation key, requirement, project, employee, dates, hours, scenario, status, notes, and Pending automation status |
| AppSheet automation bot | Apps Script web app | Webhook with shared secret in JSON body | Event ID, entity type, entity ID, and event type; response contains success, duplicate status, and calculation counts |
| Apps Script | Google Sheets output tabs | Deployment-owner authorization | Weekly capacity, weekly demand, exception records, event status, source processing status, and timestamps |
| Apps Script | Gmail | Deployment-owner Gmail authorization | Grouped exception and reminder messages; no external message identifier is required |
| Google Sheets | AppSheet dashboards | App synchronization | Calculated output records become read-only mobile views |
| Apps Script | Optional AI API | Bearer API key stored in Script Properties | Validated exceptions are sent; a structured briefing is returned and stored in AI_Briefs |
Configure an AppSheet bot for add and update events on Projects, Requirements, Allocations, EmployeeSkills, and Leave. Before the webhook task, use a data-change action to set AutomationStatus to Pending. Then send a body similar to the following:
{
"token": "YOUR_WEBHOOK_SHARED_SECRET",
"eventId": "Allocation-<<[AllocationID]>>-<<[LastModified]>>",
"entityType": "Allocation",
"entityId": "<<[AllocationID]>>",
"eventType": "UPSERT"
}
AppSheet interface labels may vary by release. The required behavior is a data-change event followed by a row update and an HTTP POST to the deployed web-app URL.
The script returns JSON such as:
{
"ok": true,
"duplicate": false,
"eventId": "Allocation-A1B2C3-2026-07-15T10:30:00",
"capacityRows": 1856,
"demandRows": 312,
"activeExceptions": 17
}
Step 5: Build the Core Automation
The core automation runs in this exact order:
- Validate the webhook secret, event ID, entity type, and entity ID.
- Acquire a script lock so two events cannot rebuild the workbook simultaneously.
- Look for the event ID in Events.
- Return a successful duplicate response when the event already completed.
- Record or update the event as Processing.
- Read projects, employees, skills, requirements, allocations, leave, and settings.
- Validate references and dates.
- Expand active requirements and allocations over the configured Monday-based planning horizon.
- Prorate weekly hours when a record covers fewer than five weekdays.
- Subtract approved leave from each employee’s base weekly capacity.
- Write CapacityWeekly and DemandWeekly.
- Generate deterministic exceptions.
- Preserve acknowledged and approved-override statuses for continuing exceptions.
- Resolve previously open exceptions that are no longer detected.
- Mark the source record and event as completed.
- Group new material exceptions by owner and send Gmail messages.
- Trigger: AppSheet webhook or five-minute Pending-row scan
- Conditions: Valid event, recognized entity, authorized token, and non-duplicate event ID
- Actions: Recalculate capacity and demand, upsert exceptions, update audit fields, and notify owners
- Fields updated: AutomationStatus, LastAutomationRun, ErrorMessage, event status, calculated output fields, and notification status
- Notification: One grouped email per owner for new high- or medium-severity exceptions
- Exception: Processing failures are recorded in Events and on the source row
Duplicate events are prevented through the Events table. Duplicate exception records are prevented with deterministic keys composed from conflict type, scenario, entity, employee, project, and week.
Users recover a failed record by correcting its data and setting AutomationStatus to Pending through an administrator action. An Apps Script administrator can also call reprocessRecord with the entity type and ID.
Step 6: Add Approvals, Reminders, and Escalations
Create AppSheet actions for Submit, Confirm, Reject, Return for Information, Acknowledge Exception, and Approve Override. Restrict each action by the signed-in user’s role.
A proposed allocation follows this approval sequence:
- The project manager submits the allocation as Proposed.
- The automation calculates its impact.
- If there is no unresolved high-severity exception, the planning manager can set it to Confirmed.
- If a blocking conflict exists, the planning manager changes the allocation, returns it, or requests an override.
- Only the operations director or delegated approver can set an exception to Override Approved.
- The action records ApprovedBy, ApprovedAt, and OverrideReason.
- The final confirmation triggers another calculation and a confirmation email when required.
Use an AppSheet action condition comparable to the following when controlling the Confirm action:
AND(
LOOKUP(USEREMAIL(), "Users", "Email", "AccessRole") = "Planner",
COUNT(
SELECT(
Exceptions[ExceptionID],
AND(
[ScenarioID] = [_THISROW].[ScenarioID],
[EmployeeID] = [_THISROW].[EmployeeID],
[Status] = "Open",
[Severity] = "High"
)
)
) = 0
)
The daily Apps Script reminder finds high- and medium-severity exceptions that remain Open or Acknowledged beyond the configured reminder age. If the owner is inactive, the script uses the active delegate from Users. If neither is valid, it routes the message to the planning mailbox.
Rejection and return actions require notes. An approved override requires the approving user, timestamp, reason, affected scenario, and exception ID. Those fields provide audit evidence without relying on email history.
Step 7: Add Documents and File Management
The core resource plan does not require a separate document for every allocation. Avoid creating unnecessary folders that increase administration.
Use the restricted Drive structure for these limited purposes:
Resource Planning/Systemfor the workbookResource Planning/Exports/YYYY/MMfor approved planning snapshotsResource Planning/Skill Evidence/EmployeeIDfor optional certificates or competency evidenceResource Planning/Documentationfor data definitions, role descriptions, recovery instructions, and release notes
If evidence files are attached through AppSheet, store only the resulting file path or Drive link in EmployeeSkills. Restrict evidence access to HR, authorized supervisors, and administrators. Project managers should see the verified proficiency result, not necessarily the underlying document.
Use a naming convention such as EmployeeID_SkillID_ValidUntil. Do not replace a file silently. Add a new file, update the current link, and retain the previous version according to the organization’s retention policy.
Missing evidence should create a review flag only when evidence is required by policy. It should not automatically reject an employee assignment. Failed uploads remain visible as incomplete records and are handled by HR or the responsible supervisor.
Step 8: Add Reporting and Operational Views
Create the following AppSheet slices and dashboard views:
- New requirements awaiting planning review
- Proposed allocations awaiting confirmation
- Employees above 100 percent utilization
- Employees with less than eight hours remaining capacity
- Unfilled requirements by project, role, and week
- Potential leave overlaps
- Skill mismatches and expiring skills
- Open and acknowledged exceptions by owner
- Overdue exceptions
- Rejected or returned proposals
- Baseline compared with Option-A or Option-B
- Upcoming project starts and completion dates
- Recently confirmed allocations
- Automation errors and failed events
- AI briefings awaiting human review
Capacity and demand source data refresh after each successful rebuild. The scheduled fallback establishes an expected maximum delay of approximately five minutes when the webhook is not used.
Useful calculated reporting measures include:
Utilization percentage = Allocated hours / Available hours
Remaining hours = Available hours - Allocated hours
Requirement gap = MAX(0, Required hours - Allocated hours)
Open exception age = Current date - Detected date
The operations planning manager owns operational dashboards. The operations director owns escalation thresholds and baseline approval. Executive views should aggregate by department, role, week, and scenario rather than exposing unnecessary employee-level details.
Step 9: Add Security and Governance Controls
- Use authenticated AppSheet users and require organizational sign-in.
- Apply security filters based on the Users table and
USEREMAIL(). - Give direct workbook edit access only to the system owner and backup owner.
- Protect output tabs and formula or configuration areas.
- Store webhook secrets and API keys in Apps Script Properties.
- Do not place credentials in AppSheet columns, email templates, or source code.
- Limit leave data to dates, hours, and approval status.
- Restrict skill-evidence files independently from planning records.
- Remove former users from AppSheet, Google Groups, Drive permissions, and the Users table promptly.
- Review Apps Script executions, failed Events rows, and Gmail delivery errors.
- Retain approved allocation and override evidence according to operational and contractual requirements.
- Back up the workbook before structural changes and export periodic baseline snapshots.
- Do not send names, emails, leave reasons, medical data, or confidential customer details to the optional AI service.
- Require human approval for allocation confirmation and all high-severity overrides.
A representative AppSheet security filter can distinguish administrators from project managers. The exact expression depends on which tables each role may see.
OR(
IN(
LOOKUP(USEREMAIL(), "Users", "Email", "AccessRole"),
{"Admin", "Planner", "Executive"}
),
[ProjectManagerEmail] = USEREMAIL()
)
Views and slices must not be treated as security controls because synchronized data may still be present on a device. Use security filters to restrict which rows are delivered.
Step 10: Deploy and Test
- Build the workbook and application in a non-production Drive folder.
- Load sample employees, projects, requirements, allocations, and leave records.
- Run
setupWorkbookand review every generated header. - Set the spreadsheet time zone and Apps Script project time zone consistently.
- Configure Script Properties for the spreadsheet ID, webhook secret, and planning email.
- Deploy the Apps Script project as a web app that executes as the automation owner.
- Configure the AppSheet webhook with the deployed URL and secret.
- Install the scheduled fallback and reminder triggers.
- Run test cases for normal, duplicate, conflict, approval, and failure paths.
- Complete user acceptance testing with one project manager, one supervisor, HR, the planning manager, and the backup administrator.
- Pilot the system with three projects for two weekly planning cycles.
- Compare calculated hours with the previous workbook before switching the remaining projects.
- Freeze the legacy workbook and retain it as read-only history.
- Activate the production application in phases by department.
- Publish a short operating guide covering status definitions, approval responsibilities, and error recovery.
If a serious calculation issue is found, disable the AppSheet webhook, pause scheduled triggers, restore the most recent workbook copy, and temporarily return to the last approved baseline snapshot. Correct and retest the script before reactivation.
Code and Configuration
The following complete Google Apps Script creates the workbook structure, processes AppSheet webhooks, rebuilds weekly capacity and demand, creates exceptions, sends Gmail notifications, installs fallback triggers, and optionally creates an AI conflict brief.
Place the script in the resource-planning workbook. Open the workbook, open the Apps Script editor from its extensions menu, replace the default editor content, and paste the complete script below.
const CFG = {
SHEETS: {
Settings: ["Key", "Value"],
Users: ["Email", "FullName", "AccessRole", "Active", "DelegateEmail"],
Projects: ["ProjectID", "ProjectName", "ClientCode", "StartDate", "EndDate",
"ProjectManagerEmail", "Priority", "Status", "ScenarioID",
"AutomationStatus", "LastModified", "LastAutomationRun", "ErrorMessage"],
Employees: ["EmployeeID", "EmployeeName", "WorkEmail", "Department",
"PrimaryRole", "WeeklyCapacityHours", "Active", "ManagerEmail",
"AutomationStatus", "LastModified", "LastAutomationRun", "ErrorMessage"],
Skills: ["SkillID", "SkillName", "Category", "Active"],
EmployeeSkills: ["EmployeeSkillID", "EmployeeID", "SkillID", "Proficiency",
"ValidUntil", "AutomationStatus", "LastModified", "LastAutomationRun",
"ErrorMessage"],
Requirements: ["RequirementID", "ProjectID", "Role", "SkillID",
"MinimumProficiency", "StartDate", "EndDate", "HoursPerWeek",
"ScenarioID", "Priority", "Status", "ApprovedBy", "ApprovedAt",
"AutomationStatus", "LastModified", "LastAutomationRun", "ErrorMessage"],
Allocations: ["AllocationID", "RequirementID", "ProjectID", "EmployeeID",
"Role", "StartDate", "EndDate", "HoursPerWeek", "ScenarioID", "Status",
"Notes", "ApprovedBy", "ApprovedAt", "OverrideReason",
"AutomationStatus", "LastModified", "LastAutomationRun", "ErrorMessage"],
Leave: ["LeaveID", "EmployeeID", "StartDate", "EndDate", "HoursPerDay",
"LeaveType", "ApprovalStatus", "AutomationStatus", "LastModified",
"LastAutomationRun", "ErrorMessage"],
CapacityWeekly: ["CapacityKey", "WeekStart", "ScenarioID", "EmployeeID",
"PrimaryRole", "BaseCapacityHours", "ApprovedLeaveHours",
"AvailableHours", "AllocatedHours", "RemainingHours",
"UtilizationPct", "LastCalculatedAt"],
DemandWeekly: ["DemandKey", "WeekStart", "ScenarioID", "RequirementID",
"ProjectID", "Role", "SkillID", "RequiredHours", "AllocatedHours",
"GapHours", "LastCalculatedAt"],
Exceptions: ["ExceptionID", "Type", "Severity", "ScenarioID", "EntityType",
"EntityID", "ProjectID", "EmployeeID", "WeekStart", "Description",
"OwnerEmail", "Status", "DetectedAt", "LastSeenAt", "ResolutionNotes",
"NotificationStatus", "NotifiedAt"],
Events: ["EventID", "ReceivedAt", "EntityType", "EntityID", "EventType",
"Status", "AttemptCount", "ErrorMessage", "CompletedAt"],
AI_Briefs: ["BriefID", "CreatedAt", "ScenarioID", "PeriodStart",
"PeriodEnd", "InputExceptionCount", "Model", "SummaryJSON",
"ReviewStatus", "ReviewerEmail", "ReviewedAt"]
},
ENTITY_MAP: {
Project: {sheet: "Projects", id: "ProjectID"},
Employee: {sheet: "Employees", id: "EmployeeID"},
Skill: {sheet: "Skills", id: "SkillID"},
EmployeeSkill: {sheet: "EmployeeSkills", id: "EmployeeSkillID"},
Requirement: {sheet: "Requirements", id: "RequirementID"},
Allocation: {sheet: "Allocations", id: "AllocationID"},
Leave: {sheet: "Leave", id: "LeaveID"}
}
};
function onOpen() {
SpreadsheetApp.getUi()
.createMenu("Resource Planning")
.addItem("Set up workbook", "setupWorkbook")
.addItem("Rebuild planning model", "manualRebuild")
.addItem("Process pending changes", "processPendingChanges")
.addItem("Generate AI conflict brief", "generateAiConflictBrief")
.addToUi();
}
function setupWorkbook() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
if (!ss) {
throw new Error("Open the bound spreadsheet before running setupWorkbook.");
}
PropertiesService.getScriptProperties()
.setProperty("SPREADSHEET_ID", ss.getId());
Object.keys(CFG.SHEETS).forEach(function(name) {
ensureSheet_(ss, name, CFG.SHEETS[name]);
});
const defaults = {
PlannerEmail: "YOUR_EMAIL_ADDRESS",
HorizonWeeks: "16",
ReminderDays: "2",
NotifySeverities: "High,Medium",
DefaultScenario: "Baseline"
};
const settings = readObjects_("Settings");
const existing = {};
settings.forEach(function(row) {
existing[String(row.Key).trim()] = true;
});
const sheet = ss.getSheetByName("Settings");
Object.keys(defaults).forEach(function(key) {
if (!existing[key]) {
sheet.appendRow([key, defaults[key]]);
}
});
console.log("Workbook setup completed for spreadsheet " + ss.getId());
}
function installTriggers() {
const handlers = ["processPendingChanges", "dailyExceptionReminder"];
ScriptApp.getProjectTriggers().forEach(function(trigger) {
if (handlers.indexOf(trigger.getHandlerFunction()) !== -1) {
ScriptApp.deleteTrigger(trigger);
}
});
ScriptApp.newTrigger("processPendingChanges")
.timeBased()
.everyMinutes(5)
.create();
ScriptApp.newTrigger("dailyExceptionReminder")
.timeBased()
.everyDays(1)
.atHour(7)
.create();
console.log("Fallback and reminder triggers installed.");
}
function doGet() {
return jsonOutput_({
ok: true,
service: "resource-planning-automation",
time: new Date().toISOString()
});
}
function doPost(e) {
try {
const raw = e && e.postData ? e.postData.contents : "";
if (!raw) {
throw new Error("Request body is empty.");
}
const payload = JSON.parse(raw);
validateWebhook_(payload);
const result = withScriptLock_(function() {
return processWebhookEvent_(payload);
});
return jsonOutput_(result);
} catch (error) {
console.error(error.stack || error);
return jsonOutput_({
ok: false,
error: safeError_(error)
});
}
}
function validateWebhook_(payload) {
const expected = PropertiesService.getScriptProperties()
.getProperty("WEBHOOK_SECRET");
if (!expected || expected === "YOUR_WEBHOOK_SHARED_SECRET") {
throw new Error("WEBHOOK_SECRET is not configured.");
}
if (!payload || String(payload.token || "") !== expected) {
throw new Error("Webhook authentication failed.");
}
if (!payload.eventId || String(payload.eventId).length > 200) {
throw new Error("A valid eventId is required.");
}
if (!CFG.ENTITY_MAP[payload.entityType]) {
throw new Error("Unsupported entityType: " + payload.entityType);
}
if (!payload.entityId || String(payload.entityId).length > 120) {
throw new Error("A valid entityId is required.");
}
const allowedEvents = ["UPSERT", "ADD", "UPDATE", "DELETE"];
if (allowedEvents.indexOf(String(payload.eventType || "UPSERT")) === -1) {
throw new Error("Unsupported eventType.");
}
}
function processWebhookEvent_(payload) {
const eventId = String(payload.eventId);
const existing = findObjectByValue_("Events", "EventID", eventId);
if (existing && String(existing.object.Status) === "Completed") {
return {
ok: true,
duplicate: true,
eventId: eventId
};
}
const attempt = existing
? Number(existing.object.AttemptCount || 0) + 1
: 1;
if (existing) {
updateObjectRow_("Events", existing.row, {
ReceivedAt: new Date(),
Status: "Processing",
AttemptCount: attempt,
ErrorMessage: "",
CompletedAt: ""
});
} else {
appendObject_("Events", {
EventID: eventId,
ReceivedAt: new Date(),
EntityType: payload.entityType,
EntityID: payload.entityId,
EventType: payload.eventType || "UPSERT",
Status: "Processing",
AttemptCount: attempt,
ErrorMessage: "",
CompletedAt: ""
});
}
try {
const result = rebuildPlanningModel_();
markSourceRecord_(
payload.entityType,
payload.entityId,
"Processed",
""
);
const current = findObjectByValue_("Events", "EventID", eventId);
updateObjectRow_("Events", current.row, {
Status: "Completed",
ErrorMessage: "",
CompletedAt: new Date()
});
try {
notifyNewExceptions_();
} catch (notificationError) {
console.error("Notification failure: " + safeError_(notificationError));
}
result.ok = true;
result.duplicate = false;
result.eventId = eventId;
return result;
} catch (error) {
const failed = findObjectByValue_("Events", "EventID", eventId);
if (failed) {
updateObjectRow_("Events", failed.row, {
Status: "Failed",
ErrorMessage: safeError_(error),
CompletedAt: new Date()
});
}
markSourceRecord_(
payload.entityType,
payload.entityId,
"Error",
safeError_(error)
);
reportFatalError_("Webhook event " + eventId, error);
throw error;
}
}
function processPendingChanges() {
return withScriptLock_(function() {
const pending = listPendingRows_();
if (pending.length === 0) {
console.log("No pending records found.");
return {ok: true, pending: 0};
}
try {
const result = rebuildPlanningModel_();
pending.forEach(function(item) {
updateObjectRow_(item.sheet, item.row, {
AutomationStatus: "Processed",
LastAutomationRun: new Date(),
ErrorMessage: ""
});
});
try {
notifyNewExceptions_();
} catch (notificationError) {
console.error("Notification failure: " + safeError_(notificationError));
}
result.ok = true;
result.pending = pending.length;
return result;
} catch (error) {
pending.forEach(function(item) {
updateObjectRow_(item.sheet, item.row, {
AutomationStatus: "Error",
LastAutomationRun: new Date(),
ErrorMessage: safeError_(error)
});
});
reportFatalError_("Scheduled pending-row processing", error);
throw error;
}
});
}
function manualRebuild() {
return withScriptLock_(function() {
const result = rebuildPlanningModel_();
notifyNewExceptions_();
console.log(JSON.stringify(result));
return result;
});
}
function reprocessRecord(entityType, entityId) {
if (!CFG.ENTITY_MAP[entityType]) {
throw new Error("Unsupported entityType.");
}
const map = CFG.ENTITY_MAP[entityType];
const record = findObjectByValue_(map.sheet, map.id, entityId);
if (!record) {
throw new Error("Record not found: " + entityType + " " + entityId);
}
updateObjectRow_(map.sheet, record.row, {
AutomationStatus: "Pending",
ErrorMessage: ""
});
return processPendingChanges();
}
function rebuildPlanningModel_() {
const ss = openBook_();
const settings = getSettings_();
const timeZone = ss.getSpreadsheetTimeZone();
const horizonWeeks = Math.max(1, Number(settings.HorizonWeeks || 16));
const now = new Date();
const firstWeek = monday_(now);
const weeks = [];
for (let i = 0; i < horizonWeeks; i++) {
const week = new Date(firstWeek);
week.setDate(week.getDate() + (i * 7));
weeks.push(week);
}
const projects = readObjects_("Projects");
const employees = readObjects_("Employees");
const employeeSkills = readObjects_("EmployeeSkills");
const requirements = readObjects_("Requirements");
const allocations = readObjects_("Allocations");
const leave = readObjects_("Leave");
const projectById = keyBy_(projects, "ProjectID");
const employeeById = keyBy_(employees, "EmployeeID");
const requirementById = keyBy_(requirements, "RequirementID");
const plannerEmail = String(settings.PlannerEmail || "");
const activeEmployees = employees.filter(function(row) {
return truthy_(row.Active);
});
const scenarios = {};
scenarios[String(settings.DefaultScenario || "Baseline")] = true;
requirements.forEach(function(row) {
if (isActiveRequirement_(row)) {
scenarios[String(row.ScenarioID || "Baseline")] = true;
}
});
allocations.forEach(function(row) {
if (isActiveAllocation_(row)) {
scenarios[String(row.ScenarioID || "Baseline")] = true;
}
});
const scenarioList = Object.keys(scenarios);
const skillMap = {};
employeeSkills.forEach(function(row) {
const key = String(row.EmployeeID) + "|" + String(row.SkillID);
skillMap[key] = {
proficiency: Number(row.Proficiency || 0),
validUntil: toDate_(row.ValidUntil)
};
});
const allocatedByEmployee = {};
const allocatedByRequirement = {};
const generatedExceptions = [];
const duplicateSignatures = {};
function addException(data) {
generatedExceptions.push(makeException_(data, now, timeZone));
}
allocations.filter(isActiveAllocation_).forEach(function(allocation) {
const employee = employeeById[String(allocation.EmployeeID)];
const project = projectById[String(allocation.ProjectID)];
const requirement = requirementById[String(allocation.RequirementID)];
const start = toDate_(allocation.StartDate);
const end = toDate_(allocation.EndDate);
const scenario = String(allocation.ScenarioID || "Baseline");
const owner = ownerFor_(project, employee, plannerEmail);
if (!employee || !project || !requirement || !start || !end || end < start) {
addException({
type: "INVALID_ALLOCATION",
severity: "High",
scenario: scenario,
entityType: "Allocation",
entityId: allocation.AllocationID,
projectId: allocation.ProjectID,
employeeId: allocation.EmployeeID,
description: "Allocation has an invalid reference or date range.",
ownerEmail: owner
});
return;
}
const projectStart = toDate_(project.StartDate);
const projectEnd = toDate_(project.EndDate);
if ((projectStart && start < projectStart) ||
(projectEnd && end > projectEnd)) {
addException({
type: "PROJECT_DATE_MISMATCH",
severity: "Medium",
scenario: scenario,
entityType: "Allocation",
entityId: allocation.AllocationID,
projectId: allocation.ProjectID,
employeeId: allocation.EmployeeID,
description: "Allocation dates extend outside the project date range.",
ownerEmail: owner
});
}
const signature = [
allocation.EmployeeID,
allocation.RequirementID,
dateKey_(start, timeZone),
dateKey_(end, timeZone),
scenario
].join("|");
if (duplicateSignatures[signature]) {
addException({
type: "DUPLICATE_ALLOCATION",
severity: "Medium",
scenario: scenario,
entityType: "Allocation",
entityId: allocation.AllocationID,
projectId: allocation.ProjectID,
employeeId: allocation.EmployeeID,
description: "Allocation duplicates another active allocation.",
ownerEmail: owner
});
} else {
duplicateSignatures[signature] = allocation.AllocationID;
}
if (requirement.SkillID) {
const skill = skillMap[
String(allocation.EmployeeID) + "|" + String(requirement.SkillID)
];
const minimum = Number(requirement.MinimumProficiency || 0);
const expired = skill && skill.validUntil && skill.validUntil < end;
if (!skill || skill.proficiency < minimum || expired) {
addException({
type: "SKILL_MISMATCH",
severity: "High",
scenario: scenario,
entityType: "Allocation",
entityId: allocation.AllocationID,
projectId: allocation.ProjectID,
employeeId: allocation.EmployeeID,
description: "Employee skill does not meet the requirement or expires before the allocation ends.",
ownerEmail: owner
});
}
}
weeks.forEach(function(week) {
const hours = hoursForWeek_(
start,
end,
Number(allocation.HoursPerWeek || 0),
week
);
if (hours <= 0) {
return;
}
const employeeKey = [
allocation.EmployeeID,
scenario,
dateKey_(week, timeZone)
].join("|");
allocatedByEmployee[employeeKey] =
Number(allocatedByEmployee[employeeKey] || 0) + hours;
const requirementKey = [
allocation.RequirementID,
scenario,
dateKey_(week, timeZone)
].join("|");
allocatedByRequirement[requirementKey] =
Number(allocatedByRequirement[requirementKey] || 0) + hours;
});
});
const capacityRows = [];
activeEmployees.forEach(function(employee) {
scenarioList.forEach(function(scenario) {
weeks.forEach(function(week) {
const base = Number(employee.WeeklyCapacityHours || 0);
let leaveHours = 0;
leave.forEach(function(item) {
if (String(item.EmployeeID) !== String(employee.EmployeeID) ||
String(item.ApprovalStatus) !== "Approved") {
return;
}
const start = toDate_(item.StartDate);
const end = toDate_(item.EndDate);
if (!start || !end) {
return;
}
leaveHours += weekdayOverlap_(start, end, week) *
Number(item.HoursPerDay || 0);
});
const key = [
employee.EmployeeID,
scenario,
dateKey_(week, timeZone)
].join("|");
const allocated = Number(allocatedByEmployee[key] || 0);
const available = Math.max(0, base - leaveHours);
const remaining = available - allocated;
const utilization = available > 0 ? allocated / available : "";
capacityRows.push({
CapacityKey: [
scenario,
employee.EmployeeID,
dateKey_(week, timeZone)
].join("|"),
WeekStart: week,
ScenarioID: scenario,
EmployeeID: employee.EmployeeID,
PrimaryRole: employee.PrimaryRole,
BaseCapacityHours: round2_(base),
ApprovedLeaveHours: round2_(leaveHours),
AvailableHours: round2_(available),
AllocatedHours: round2_(allocated),
RemainingHours: round2_(remaining),
UtilizationPct: utilization === "" ? "" : round4_(utilization),
LastCalculatedAt: now
});
const owner = ownerFor_(
null,
employee,
plannerEmail
);
if (allocated > available + 0.01) {
addException({
type: "EMPLOYEE_OVERALLOCATED",
severity: allocated - available >= 8 ? "High" : "Medium",
scenario: scenario,
entityType: "EmployeeWeek",
entityId: employee.EmployeeID + "|" + dateKey_(week, timeZone),
employeeId: employee.EmployeeID,
weekStart: week,
description: "Employee " + employee.EmployeeID +
" is allocated " + round2_(allocated) +
" hours against " + round2_(available) +
" available hours.",
ownerEmail: owner
});
}
if (leaveHours > 0 && allocated > 0) {
addException({
type: "POTENTIAL_LEAVE_OVERLAP",
severity: "Medium",
scenario: scenario,
entityType: "EmployeeWeek",
entityId: employee.EmployeeID + "|" + dateKey_(week, timeZone),
employeeId: employee.EmployeeID,
weekStart: week,
description: "Employee " + employee.EmployeeID +
" has approved leave and project allocation in the same week.",
ownerEmail: owner
});
}
});
});
});
const demandRows = [];
requirements.filter(isActiveRequirement_).forEach(function(requirement) {
const project = projectById[String(requirement.ProjectID)];
const start = toDate_(requirement.StartDate);
const end = toDate_(requirement.EndDate);
const scenario = String(requirement.ScenarioID || "Baseline");
const owner = ownerFor_(project, null, plannerEmail);
if (!project || !start || !end || end < start) {
addException({
type: "INVALID_REQUIREMENT",
severity: "High",
scenario: scenario,
entityType: "Requirement",
entityId: requirement.RequirementID,
projectId: requirement.ProjectID,
description: "Requirement has an invalid project reference or date range.",
ownerEmail: owner
});
return;
}
weeks.forEach(function(week) {
const required = hoursForWeek_(
start,
end,
Number(requirement.HoursPerWeek || 0),
week
);
if (required <= 0) {
return;
}
const key = [
requirement.RequirementID,
scenario,
dateKey_(week, timeZone)
].join("|");
const allocated = Number(allocatedByRequirement[key] || 0);
const gap = Math.max(0, required - allocated);
demandRows.push({
DemandKey: [
scenario,
requirement.RequirementID,
dateKey_(week, timeZone)
].join("|"),
WeekStart: week,
ScenarioID: scenario,
RequirementID: requirement.RequirementID,
ProjectID: requirement.ProjectID,
Role: requirement.Role,
SkillID: requirement.SkillID,
RequiredHours: round2_(required),
AllocatedHours: round2_(allocated),
GapHours: round2_(gap),
LastCalculatedAt: now
});
if (gap > 0.01) {
const highPriority =
String(requirement.Priority) === "Critical" || gap >= 16;
addException({
type: "REQUIREMENT_UNFILLED",
severity: highPriority ? "High" : "Medium",
scenario: scenario,
entityType: "RequirementWeek",
entityId: requirement.RequirementID + "|" + dateKey_(week, timeZone),
projectId: requirement.ProjectID,
weekStart: week,
description: "Requirement " + requirement.RequirementID +
" has an unfilled gap of " + round2_(gap) + " hours.",
ownerEmail: owner
});
}
});
});
writeObjects_("CapacityWeekly", capacityRows);
writeObjects_("DemandWeekly", demandRows);
const exceptionResult = upsertExceptions_(generatedExceptions, now);
return {
capacityRows: capacityRows.length,
demandRows: demandRows.length,
activeExceptions: exceptionResult.active,
resolvedExceptions: exceptionResult.resolved
};
}
function makeException_(data, now, timeZone) {
const weekKey = data.weekStart
? dateKey_(data.weekStart, timeZone)
: "";
const identity = [
data.type,
data.scenario || "Baseline",
data.entityType || "",
data.entityId || "",
data.projectId || "",
data.employeeId || "",
weekKey
].join("|");
return {
ExceptionID: "EX-" + hashText_(identity).substring(0, 16),
Type: data.type,
Severity: data.severity || "Medium",
ScenarioID: data.scenario || "Baseline",
EntityType: data.entityType || "",
EntityID: data.entityId || "",
ProjectID: data.projectId || "",
EmployeeID: data.employeeId || "",
WeekStart: data.weekStart || "",
Description: data.description || "",
OwnerEmail: data.ownerEmail || "",
Status: "Open",
DetectedAt: now,
LastSeenAt: now,
ResolutionNotes: "",
NotificationStatus: "Pending",
NotifiedAt: ""
};
}
function upsertExceptions_(generated, now) {
const oldRows = readObjects_("Exceptions");
const oldById = keyBy_(oldRows, "ExceptionID");
const generatedIds = {};
const output = [];
let active = 0;
let resolved = 0;
generated.forEach(function(item) {
generatedIds[String(item.ExceptionID)] = true;
const old = oldById[String(item.ExceptionID)];
if (old) {
item.DetectedAt = old.DetectedAt || item.DetectedAt;
item.ResolutionNotes = old.ResolutionNotes || "";
if (String(old.Status) === "Override Approved") {
item.Status = "Override Approved";
} else if (String(old.Status) === "Acknowledged") {
item.Status = "Acknowledged";
} else {
item.Status = "Open";
}
if (String(old.Status) === "Resolved") {
item.NotificationStatus = "Pending";
item.NotifiedAt = "";
} else {
item.NotificationStatus = old.NotificationStatus || "Pending";
item.NotifiedAt = old.NotifiedAt || "";
}
}
output.push(item);
active++;
});
oldRows.forEach(function(old) {
if (!generatedIds[String(old.ExceptionID)]) {
if (String(old.Status) !== "Resolved") {
old.Status = "Resolved";
old.LastSeenAt = now;
resolved++;
}
output.push(old);
}
});
output.sort(function(a, b) {
return String(a.ExceptionID).localeCompare(String(b.ExceptionID));
});
writeObjects_("Exceptions", output);
return {active: active, resolved: resolved};
}
function notifyNewExceptions_() {
const settings = getSettings_();
const permitted = String(settings.NotifySeverities || "High,Medium")
.split(",")
.map(function(value) {
return value.trim();
});
const exceptions = readObjects_("Exceptions");
const groups = {};
let changed = false;
exceptions.forEach(function(item) {
if (String(item.Status) !== "Open" ||
String(item.NotificationStatus) !== "Pending") {
return;
}
if (permitted.indexOf(String(item.Severity)) === -1) {
item.NotificationStatus = "Suppressed";
changed = true;
return;
}
const recipient = effectiveRecipient_(
item.OwnerEmail,
settings.PlannerEmail
);
if (!recipient) {
item.NotificationStatus = "Invalid recipient";
changed = true;
return;
}
if (!groups[recipient]) {
groups[recipient] = [];
}
groups[recipient].push(item);
});
Object.keys(groups).forEach(function(recipient) {
const items = groups[recipient];
const body = [
"New resource-planning exceptions require review.",
"",
items.slice(0, 30).map(function(item) {
return [
item.Severity,
item.Type,
item.ExceptionID,
item.ProjectID || "No project",
item.EmployeeID || "No employee",
item.Description
].join(" | ");
}).join("\n"),
"",
"Open the resource-planning application to acknowledge or resolve them."
].join("\n");
try {
withRetry_(function() {
GmailApp.sendEmail(
recipient,
"Resource planning exceptions: " + items.length + " new",
body,
{name: "Resource Planning Automation"}
);
}, 3);
items.forEach(function(item) {
item.NotificationStatus = "Sent";
item.NotifiedAt = new Date();
changed = true;
});
} catch (error) {
console.error("Email to " + recipient + " failed: " + safeError_(error));
}
});
if (changed) {
writeObjects_("Exceptions", exceptions);
}
}
function dailyExceptionReminder() {
return withScriptLock_(function() {
const settings = getSettings_();
const reminderDays = Math.max(1, Number(settings.ReminderDays || 2));
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - reminderDays);
const groups = {};
readObjects_("Exceptions").forEach(function(item) {
if (["Open", "Acknowledged"].indexOf(String(item.Status)) === -1) {
return;
}
if (["High", "Medium"].indexOf(String(item.Severity)) === -1) {
return;
}
const detected = toDate_(item.DetectedAt);
if (!detected || detected > cutoff) {
return;
}
const recipient = effectiveRecipient_(
item.OwnerEmail,
settings.PlannerEmail
);
if (!recipient) {
return;
}
if (!groups[recipient]) {
groups[recipient] = [];
}
groups[recipient].push(item);
});
Object.keys(groups).forEach(function(recipient) {
const items = groups[recipient];
const body = [
"Resource-planning exceptions remain unresolved.",
"",
items.slice(0, 40).map(function(item) {
return [
item.Severity,
item.ExceptionID,
item.Type,
item.Description
].join(" | ");
}).join("\n"),
"",
"Review ownership, resolution, or escalation in the planning application."
].join("\n");
withRetry_(function() {
GmailApp.sendEmail(
recipient,
"Resource planning reminder: " + items.length + " unresolved",
body,
{name: "Resource Planning Automation"}
);
}, 3);
});
return {ok: true, recipients: Object.keys(groups).length};
});
}
function generateAiConflictBrief() {
return withScriptLock_(function() {
const props = PropertiesService.getScriptProperties();
const apiKey = props.getProperty("AI_API_KEY");
const model = props.getProperty("AI_MODEL");
if (!apiKey || apiKey === "YOUR_API_KEY") {
throw new Error("AI_API_KEY is not configured.");
}
if (!model || model === "YOUR_MODEL_NAME") {
throw new Error("AI_MODEL is not configured.");
}
const settings = getSettings_();
const ss = openBook_();
const timeZone = ss.getSpreadsheetTimeZone();
const openItems = readObjects_("Exceptions")
.filter(function(item) {
return ["Open", "Acknowledged"].indexOf(String(item.Status)) !== -1 &&
["High", "Medium"].indexOf(String(item.Severity)) !== -1;
})
.slice(0, 60);
if (openItems.length === 0) {
throw new Error("No open high- or medium-severity exceptions exist.");
}
const input = {
period_start: dateKey_(monday_(new Date()), timeZone),
horizon_weeks: Number(settings.HorizonWeeks || 16),
exceptions: openItems.map(function(item) {
return {
exception_id: String(item.ExceptionID),
type: String(item.Type),
severity: String(item.Severity),
scenario_id: String(item.ScenarioID),
project_id: String(item.ProjectID || ""),
employee_ref: String(item.EmployeeID || ""),
week_start: item.WeekStart
? dateKey_(toDate_(item.WeekStart), timeZone)
: "",
description: redactEmails_(String(item.Description || ""))
};
})
};
const systemInstruction = [
"You summarize validated resource-planning exceptions for a human operations planner.",
"Use only facts present in the supplied JSON.",
"Do not infer employee performance, health, protected characteristics, or customer information.",
"Do not approve, reject, assign, or override work.",
"Prioritize by supplied severity, timing, and explicit capacity gap.",
"Reference only exception IDs present in the input.",
"Return JSON that exactly matches the required schema."
].join(" ");
const schema = {
type: "object",
additionalProperties: false,
required: ["overview", "priorities", "data_quality_notes"],
properties: {
overview: {type: "string"},
priorities: {
type: "array",
maxItems: 12,
items: {
type: "object",
additionalProperties: false,
required: [
"exception_id",
"risk",
"summary",
"recommended_next_step",
"questions_for_planner"
],
properties: {
exception_id: {type: "string"},
risk: {type: "string", enum: ["high", "medium", "low"]},
summary: {type: "string"},
recommended_next_step: {type: "string"},
questions_for_planner: {
type: "array",
items: {type: "string"}
}
}
}
},
data_quality_notes: {
type: "array",
items: {type: "string"}
}
}
};
const requestBody = {
model: model,
messages: [
{role: "system", content: systemInstruction},
{
role: "user",
content: "Create the planning brief from this validated JSON:\n" +
JSON.stringify(input)
}
],
response_format: {
type: "json_schema",
json_schema: {
name: "resource_conflict_brief",
strict: true,
schema: schema
}
}
};
const response = fetchJsonWithRetry_(
"https://api.openai.com/v1/chat/completions",
{
method: "post",
contentType: "application/json",
headers: {
Authorization: "Bearer " + apiKey
},
payload: JSON.stringify(requestBody),
muteHttpExceptions: true
},
4
);
if (!response.choices ||
!response.choices[0] ||
!response.choices[0].message ||
!response.choices[0].message.content) {
throw new Error("AI response did not contain structured content.");
}
const brief = JSON.parse(response.choices[0].message.content);
validateAiBrief_(brief, input.exceptions);
const periodStart = monday_(new Date());
const periodEnd = new Date(periodStart);
periodEnd.setDate(
periodEnd.getDate() + (Number(settings.HorizonWeeks || 16) * 7) - 1
);
appendObject_("AI_Briefs", {
BriefID: "BR-" + hashText_(
new Date().toISOString() + JSON.stringify(brief)
).substring(0, 16),
CreatedAt: new Date(),
ScenarioID: "Multiple",
PeriodStart: periodStart,
PeriodEnd: periodEnd,
InputExceptionCount: input.exceptions.length,
Model: model,
SummaryJSON: JSON.stringify(brief),
ReviewStatus: "Pending Human Review",
ReviewerEmail: "",
ReviewedAt: ""
});
console.log(JSON.stringify(brief));
return brief;
});
}
function validateAiBrief_(brief, inputExceptions) {
if (!brief || typeof brief.overview !== "string" ||
!Array.isArray(brief.priorities) ||
!Array.isArray(brief.data_quality_notes)) {
throw new Error("AI output failed schema validation.");
}
const allowedIds = {};
inputExceptions.forEach(function(item) {
allowedIds[String(item.exception_id)] = true;
});
brief.priorities.forEach(function(item) {
if (!allowedIds[String(item.exception_id)]) {
throw new Error("AI output referenced an unknown exception ID.");
}
if (["high", "medium", "low"].indexOf(String(item.risk)) === -1) {
throw new Error("AI output returned an invalid risk value.");
}
});
}
function fetchJsonWithRetry_(url, options, attempts) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const response = UrlFetchApp.fetch(url, options);
const status = response.getResponseCode();
const text = response.getContentText();
if (status >= 200 && status < 300) {
return JSON.parse(text);
}
if (status !== 429 && status < 500) {
throw new Error("AI API returned HTTP " + status + ": " + text);
}
lastError = new Error("Transient AI API error HTTP " + status);
} catch (error) {
lastError = error;
}
Utilities.sleep(Math.pow(2, attempt) * 1000);
}
throw lastError || new Error("AI API request failed.");
}
function listPendingRows_() {
const sourceSheets = [
"Projects",
"Employees",
"EmployeeSkills",
"Requirements",
"Allocations",
"Leave"
];
const pending = [];
sourceSheets.forEach(function(name) {
readObjectsWithRows_(name).forEach(function(item) {
if (String(item.object.AutomationStatus) === "Pending") {
pending.push({
sheet: name,
row: item.row
});
}
});
});
return pending;
}
function markSourceRecord_(entityType, entityId, status, errorMessage) {
const map = CFG.ENTITY_MAP[entityType];
if (!map) {
return;
}
const record = findObjectByValue_(map.sheet, map.id, entityId);
if (!record) {
return;
}
updateObjectRow_(map.sheet, record.row, {
AutomationStatus: status,
LastAutomationRun: new Date(),
ErrorMessage: errorMessage || ""
});
}
function effectiveRecipient_(ownerEmail, fallbackEmail) {
const users = readObjects_("Users");
const owner = String(ownerEmail || "").trim().toLowerCase();
const fallback = String(fallbackEmail || "").trim().toLowerCase();
const user = users.find(function(item) {
return String(item.Email || "").trim().toLowerCase() === owner;
});
if (user && truthy_(user.Active) && validEmail_(owner)) {
return owner;
}
if (user && validEmail_(String(user.DelegateEmail || ""))) {
return String(user.DelegateEmail).trim();
}
return validEmail_(fallback) ? fallback : "";
}
function ownerFor_(project, employee, fallback) {
if (project && validEmail_(String(project.ProjectManagerEmail || ""))) {
return String(project.ProjectManagerEmail).trim();
}
if (employee && validEmail_(String(employee.ManagerEmail || ""))) {
return String(employee.ManagerEmail).trim();
}
return validEmail_(String(fallback || "")) ? String(fallback).trim() : "";
}
function isActiveRequirement_(row) {
return ["Active", "Proposed"].indexOf(String(row.Status)) !== -1;
}
function isActiveAllocation_(row) {
return ["Confirmed", "Proposed"].indexOf(String(row.Status)) !== -1;
}
function hoursForWeek_(start, end, hoursPerWeek, weekStart) {
if (!start || !end || !hoursPerWeek) {
return 0;
}
return hoursPerWeek * weekdayOverlap_(start, end, weekStart) / 5;
}
function weekdayOverlap_(rangeStart, rangeEnd, weekStart) {
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 6);
const start = rangeStart > weekStart
? new Date(rangeStart)
: new Date(weekStart);
const end = rangeEnd < weekEnd
? new Date(rangeEnd)
: weekEnd;
if (end < start) {
return 0;
}
let count = 0;
const cursor = new Date(start);
cursor.setHours(0, 0, 0, 0);
while (cursor <= end) {
const day = cursor.getDay();
if (day !== 0 && day !== 6) {
count++;
}
cursor.setDate(cursor.getDate() + 1);
}
return count;
}
function monday_(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
const offset = (date.getDay() + 6) % 7;
date.setDate(date.getDate() - offset);
return date;
}
function toDate_(value) {
if (!value) {
return null;
}
if (value instanceof Date && !isNaN(value.getTime())) {
return new Date(value);
}
const dateOnly = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (dateOnly) {
return new Date(
Number(dateOnly[1]),
Number(dateOnly[2]) - 1,
Number(dateOnly[3])
);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
function dateKey_(date, timeZone) {
return Utilities.formatDate(date, timeZone, "yyyy-MM-dd");
}
function round2_(number) {
return Math.round((Number(number) + Number.EPSILON) * 100) / 100;
}
function round4_(number) {
return Math.round((Number(number) + Number.EPSILON) * 10000) / 10000;
}
function truthy_(value) {
return value === true ||
["true", "yes", "1", "active"].indexOf(
String(value || "").trim().toLowerCase()
) !== -1;
}
function validEmail_(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || "").trim());
}
function redactEmails_(value) {
return String(value).replace(
/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi,
"[email removed]"
);
}
function hashText_(value) {
const bytes = Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(value),
Utilities.Charset.UTF_8
);
return bytes.map(function(byte) {
const unsigned = byte < 0 ? byte + 256 : byte;
return ("0" + unsigned.toString(16)).slice(-2);
}).join("");
}
function withScriptLock_(callback) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) {
throw new Error("Another planning calculation is running.");
}
try {
return callback();
} finally {
lock.releaseLock();
}
}
function withRetry_(callback, attempts) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return callback();
} catch (error) {
lastError = error;
Utilities.sleep(Math.pow(2, attempt) * 1000);
}
}
throw lastError;
}
function reportFatalError_(context, error) {
try {
const settings = getSettings_();
const recipient = String(settings.PlannerEmail || "");
if (!validEmail_(recipient)) {
console.error(context + ": " + safeError_(error));
return;
}
withRetry_(function() {
GmailApp.sendEmail(
recipient,
"Resource planning automation failure",
context + "\n\n" + safeError_(error),
{name: "Resource Planning Automation"}
);
}, 2);
} catch (secondaryError) {
console.error("Failure notification also failed: " +
safeError_(secondaryError));
}
}
function safeError_(error) {
return String(error && error.message ? error.message : error)
.substring(0, 1000);
}
function getSettings_() {
const result = {};
readObjects_("Settings").forEach(function(row) {
result[String(row.Key).trim()] = row.Value;
});
return result;
}
function keyBy_(rows, keyName) {
const result = {};
rows.forEach(function(row) {
const key = String(row[keyName] || "");
if (key) {
result[key] = row;
}
});
return result;
}
function openBook_() {
const id = PropertiesService.getScriptProperties()
.getProperty("SPREADSHEET_ID");
if (!id || id === "YOUR_SPREADSHEET_ID") {
throw new Error("SPREADSHEET_ID is not configured.");
}
return SpreadsheetApp.openById(id);
}
function ensureSheet_(ss, name, headers) {
let sheet = ss.getSheetByName(name);
if (!sheet) {
sheet = ss.insertSheet(name);
}
const lastColumn = sheet.getLastColumn();
const lastRow = sheet.getLastRow();
if (lastRow === 0 || lastColumn === 0) {
ensureSheetSize_(sheet, 1, headers.length);
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
sheet.setFrozenRows(1);
return;
}
const existing = sheet.getRange(
1,
1,
1,
Math.min(lastColumn, headers.length)
).getValues()[0];
for (let i = 0; i < existing.length; i++) {
if (String(existing[i]) !== String(headers[i])) {
throw new Error(
"Header mismatch in " + name + " at column " + (i + 1)
);
}
}
if (lastColumn < headers.length) {
ensureSheetSize_(sheet, 1, headers.length);
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
}
sheet.setFrozenRows(1);
}
function ensureSheetSize_(sheet, rows, columns) {
if (sheet.getMaxRows() < rows) {
sheet.insertRowsAfter(
sheet.getMaxRows(),
rows - sheet.getMaxRows()
);
}
if (sheet.getMaxColumns() < columns) {
sheet.insertColumnsAfter(
sheet.getMaxColumns(),
columns - sheet.getMaxColumns()
);
}
}
function readObjects_(name) {
return readObjectsWithRows_(name).map(function(item) {
return item.object;
});
}
function readObjectsWithRows_(name) {
const ss = openBook_();
const sheet = ss.getSheetByName(name);
const headers = CFG.SHEETS[name];
if (!sheet || sheet.getLastRow() < 2) {
return [];
}
const values = sheet.getRange(
2,
1,
sheet.getLastRow() - 1,
headers.length
).getValues();
return values.map(function(row, index) {
const object = {};
headers.forEach(function(header, column) {
object[header] = row[column];
});
return {
row: index + 2,
object: object
};
}).filter(function(item) {
return Object.keys(item.object).some(function(key) {
return item.object[key] !== "";
});
});
}
function writeObjects_(name, objects) {
const ss = openBook_();
const sheet = ss.getSheetByName(name);
const headers = CFG.SHEETS[name];
ensureSheetSize_(sheet, Math.max(2, objects.length + 1), headers.length);
if (sheet.getLastRow() > 1) {
sheet.getRange(
2,
1,
sheet.getLastRow() - 1,
headers.length
).clearContent();
}
if (objects.length === 0) {
return;
}
const values = objects.map(function(object) {
return headers.map(function(header) {
return object[header] === undefined ? "" : object[header];
});
});
sheet.getRange(2, 1, values.length, headers.length).setValues(values);
}
function appendObject_(name, object) {
const ss = openBook_();
const sheet = ss.getSheetByName(name);
const headers = CFG.SHEETS[name];
sheet.appendRow(headers.map(function(header) {
return object[header] === undefined ? "" : object[header];
}));
}
function findObjectByValue_(name, field, value) {
const rows = readObjectsWithRows_(name);
for (let i = 0; i < rows.length; i++) {
if (String(rows[i].object[field]) === String(value)) {
return rows[i];
}
}
return null;
}
function updateObjectRow_(name, rowNumber, updates) {
const ss = openBook_();
const sheet = ss.getSheetByName(name);
const headers = CFG.SHEETS[name];
const current = sheet.getRange(
rowNumber,
1,
1,
headers.length
).getValues()[0];
Object.keys(updates).forEach(function(field) {
const index = headers.indexOf(field);
if (index !== -1) {
current[index] = updates[field];
}
});
sheet.getRange(
rowNumber,
1,
1,
headers.length
).setValues([current]);
}
function jsonOutput_(object) {
return ContentService
.createTextOutput(JSON.stringify(object))
.setMimeType(ContentService.MimeType.JSON);
}
Configuration and Deployment
- Save the script and run
setupWorkbookmanually. - Review the requested permissions. The script requires spreadsheet access, Gmail sending, external HTTP requests for optional AI, trigger management, and script-property access.
- Open the Apps Script project settings and add
WEBHOOK_SECRETwith a long random value. - Confirm that
SPREADSHEET_IDwas stored by the setup function. If necessary, add it manually usingYOUR_SPREADSHEET_IDas the replacement pattern. - Replace
YOUR_EMAIL_ADDRESSin Settings with the planning mailbox. - Run
installTriggersand authorize the scheduled triggers. - Deploy the script as a web application that executes as the automation owner. Select an access option permitted by the organization’s policy and compatible with the AppSheet webhook.
- Copy the deployment URL into the AppSheet webhook task.
- Use the same secret in the webhook JSON. Keep it visible only to application administrators.
- Submit a test allocation and inspect Apps Script Executions, Events, CapacityWeekly, DemandWeekly, and Exceptions.
The webhook is the immediate trigger. The five-minute time trigger is the recovery path. Duplicate processing is prevented by EventID, while concurrent calculations are prevented by a script lock.
Execution logs are available in the Apps Script Executions view. Business-level failures are also written to Events and the source record’s ErrorMessage field.
For optional AI, add Script Properties named AI_API_KEY and AI_MODEL. Select a model that supports the configured structured-output format. The API key is sent in the Authorization header as a bearer token. Requests use an HTTP POST with JSON content and retry HTTP 429 and server errors with exponential delays.
If the AI provider or selected model does not support the shown structured-output option, disable the AI trigger and update the request configuration only after validating the provider’s current official API documentation. The core planning automation does not depend on AI.
Failure Handling and Operational Reliability
| Failure | Automated response | Manual recovery | Owner |
|---|---|---|---|
| Missing required value | AppSheet blocks submission or the script creates an invalid-record exception | Complete the field and resubmit | Record creator |
| Duplicate webhook | Completed EventID returns success without rebuilding | None | Automation |
| Duplicate allocation | Validation blocks exact duplicates; script also creates an exception | Cancel or correct one record | Project manager |
| Invalid reference | High-severity exception and Error context | Restore the referenced record or update the reference | System owner |
| Partial processing | Event remains Failed and source row becomes Error | Review outputs, correct the cause, and reprocess | System owner |
| Webhook unavailable | Source row remains Pending until five-minute scan | Confirm scheduled trigger is active | System owner |
| Authentication failure | Webhook receives an error response; no processing occurs | Replace the secret in Script Properties and AppSheet | Administrator |
| Concurrent events | Script lock serializes calculations; a timed-out event can retry | Inspect Events if repeated lock errors occur | System owner |
| Gmail failure | Exception remains unsent and error is logged | Verify account, quota, address, and authorization; rerun notification | System owner |
| Unavailable approver | Reminder routes to an active delegate or planning mailbox | Update Users and reassign ownership | Planning manager |
| Failed file upload | Evidence link remains incomplete | Upload again from a stable connection | Supervisor or HR |
| AI rate limit or timeout | Request retries and then fails without changing operational data | Create the planning summary manually or rerun later | Planning manager |
| Malformed AI output | Validation rejects the response and no brief is stored | Review model compatibility and retry | System owner |
| Spreadsheet structure changed | Header checks or missing fields produce a logged failure | Restore expected headers from documentation or backup | System owner |
The Events table acts as a lightweight dead-letter queue for failed webhook events. It shows entity, record ID, attempt count, failure message, and completion time.
Reconciliation consists of comparing Pending and Error source rows with failed Events, confirming that every active employee-scenario-week has a CapacityWeekly row, and reviewing the last successful calculation timestamp.
Rate-limit and quota monitoring remains important. AppSheet synchronization volume, Gmail sending, Apps Script runtime, spreadsheet size, and optional AI API use must remain within the organization’s current service limits.
A Complete Example
A project manager enters a new electrical commissioning requirement for project PRJ-24017:
- Requirement ID:
REQ-A81F2C - Role: Controls Engineer
- Required skill: PLC-ADV
- Minimum proficiency: 4
- Date range: August 3 through August 21
- Demand: 32 hours per week
- Scenario: Baseline
- Priority: High
The supervisor proposes employee EMP-1042 for 24 hours per week. AppSheet creates allocation ALC-91D4BE, writes it to Allocations with status Proposed, and marks AutomationStatus as Pending.
The AppSheet bot sends an event such as Allocation-ALC-91D4BE-2026-07-15T10:30:00. Apps Script records that event as Processing and reads the operational tables.
The script confirms that the employee has proficiency 4 for PLC-ADV. It then finds these validated facts for the week beginning August 3:
- Base capacity: 40 hours
- Approved leave: 8 hours
- Available capacity: 32 hours
- Existing confirmed allocation: 20 hours
- New proposed allocation: 24 hours
- Total allocation: 44 hours
- Overallocated amount: 12 hours
- Requirement gap: 8 hours because only 24 of 32 required hours are named
The automation writes a CapacityWeekly record for Baseline|EMP-1042|2026-08-03. It creates a high-severity employee-overallocated exception, a medium potential-leave-overlap exception, and a medium unfilled-requirement exception.
The project manager receives one grouped Gmail alert. The allocation remains Proposed. The planning manager can change the assignment, split eight hours to another qualified employee, alter the dates, or request a documented override. The script does not choose among those options.
The manager assigns employee EMP-1088 for the missing eight hours and reduces EMP-1042 to 12 hours. A second event recalculates the plan. The capacity and requirement exceptions are no longer detected, so their statuses become Resolved. The planning manager confirms both allocations, with approver and timestamp stored in the register.
Implementation Cost
All figures below are representative budgeting assumptions, not published product pricing or verified client results. Licensing must be confirmed with current vendors and adjusted for the number and type of users.
| Item | Assumption | Estimated amount |
|---|---|---|
| Discovery and process definition | 24 internal hours at $55 per hour | $1,320 |
| Workbook, AppSheet, and script configuration | 56 internal hours at $55 per hour | $3,080 |
| Testing and reconciliation | 20 internal hours at $55 per hour | $1,100 |
| Training | 8 internal hours at $55 per hour | $440 |
| Documentation and handover | 8 internal hours at $55 per hour | $440 |
| External technical review | 12 hours at an assumed $135 per hour | $1,620 |
| Representative implementation total | Internal labor plus external review | $8,000 |
| Item | Assumption | Estimated amount |
|---|---|---|
| AppSheet licensing allowance | Budget allowance for 18 authorized users; replace with a current vendor quote | $216 |
| Usage contingency | Small operating allowance | $4 |
| Google Workspace tools | Existing organizational agreement; assumed no incremental license in this scenario | $0 incremental |
| Core recurring tool allowance | Used in the savings estimate | $220 |
| Maintenance labor | 3 internal hours at $55 per hour | $165 |
| Optional AI usage | Budget allowance based on controlled briefing volume | $18 |
An externally led implementation could instead be budgeted as a professional services engagement. A representative planning range might be $12,000 to $18,000 depending on data cleanup, security review, AppSheet complexity, integrations, training, and support. This would normally replace some internal build effort rather than simply being added to it.
Estimated Time and Cost Savings
The representative estimate uses these assumptions:
- 220 planning changes per month
- 13 minutes of current handling per change
- 4 minutes of handling after core automation
- 12 percent of records require exception review
- 8 minutes of review for each exception record
- 3 hours of monthly system maintenance
- $48 loaded hourly labor cost for planning work
- $220 recurring monthly core tool allowance
- $8,000 one-time implementation cost
Current monthly labour hours: Monthly volume × current minutes per record ÷ 60
New monthly labour hours: Monthly volume × new minutes per record ÷ 60, plus exception handling and maintenance
Monthly hours recovered: Current monthly labour hours minus new monthly labour hours
Estimated monthly labour value: Monthly hours recovered × loaded hourly labour cost
Net estimated monthly value: Monthly labour value minus recurring tool costs
Estimated payback period: One-time implementation cost ÷ net estimated monthly value
| Calculation | Formula | Result |
|---|---|---|
| Current monthly labor | 220 × 13 ÷ 60 | 47.67 hours |
| New direct handling | 220 × 4 ÷ 60 | 14.67 hours |
| Exception handling | 220 × 12% × 8 ÷ 60 | 3.52 hours |
| Maintenance | Monthly assumption | 3.00 hours |
| Total new labor | 14.67 + 3.52 + 3.00 | 21.19 hours |
| Monthly hours recovered | 47.67 – 21.19 | 26.48 hours |
| Monthly labor value | 26.48 × $48 | $1,271.04 |
| Net monthly value | $1,271.04 – $220 | $1,051.04 |
| Estimated payback | $8,000 ÷ $1,051.04 | 7.61 months |
Recovered time does not automatically reduce payroll. It may represent additional planning capacity, quicker response to project changes, reduced overtime, fewer administrative updates, and less dependency on the planning manager.
Non-financial benefits include clearer ownership, fewer follow-up emails, more consistent statuses, earlier visibility of shortages, auditable overrides, dependable scenario comparison, and better planning-meeting preparation.
Readers should replace the monthly volume, handling times, exception rate, review time, labor cost, licensing allowance, maintenance effort, and implementation cost with their own measured figures.
Adding AI to the Automation
AI should be added only after the underlying project, employee, skill, leave, allocation, and exception data is validated. It is not needed for capacity arithmetic, date comparison, exact duplicate detection, approval thresholds, or access control.
Useful optional AI applications include:
- Summarizing a long exception queue for the planning meeting
- Grouping related conflicts across projects and weeks
- Drafting questions for project managers
- Identifying descriptions that appear incomplete or inconsistent
- Summarizing scenario differences after deterministic calculations
- Supporting semantic search across approved planning notes
The core automation provides mobile entry, synchronization, capacity calculations, validation, exception generation, reminders, and reporting. AI adds support for reading and summarizing unstructured exception information. It does not create the underlying operational accuracy.
The Recommended AI Enhancement
The recommended enhancement is a planning brief generated from validated open exceptions. It runs manually or on a controlled daily schedule after the deterministic calculation completes.
- Trigger: Planning manager action or daily scheduled execution
- AI input: Exception ID, type, severity, scenario, project ID, pseudonymous employee reference, week, and validated description
- Excluded data: Names, emails, leave reasons, medical information, protected characteristics, customer details, and document contents
- Output: Structured JSON containing an overview, prioritized exceptions, suggested review steps, questions, and data-quality notes
- Validation: Every returned exception ID must exist in the input and every risk value must use the allowed enumeration
- Record update: A new AI_Briefs row is stored with Pending Human Review status
- Human review: The planning manager accepts, edits, or discards the briefing
- Failure behavior: No operational records change; the planner uses the normal exception dashboard
The reusable system instruction is:
You summarize validated resource-planning exceptions for a human operations planner. Use only facts present in the supplied JSON. Do not infer employee performance, health, protected characteristics, or customer information. Do not approve, reject, assign, or override work. Prioritize by supplied severity, timing, and explicit capacity gap. Reference only exception IDs present in the input. Return JSON that exactly matches the required schema.
The reusable user prompt is:
Create the planning brief from this validated JSON:
{
"period_start": "YYYY-MM-DD",
"horizon_weeks": 16,
"exceptions": [
{
"exception_id": "EX-IDENTIFIER",
"type": "EMPLOYEE_OVERALLOCATED",
"severity": "High",
"scenario_id": "Baseline",
"project_id": "PRJ-ID",
"employee_ref": "EMP-ID",
"week_start": "YYYY-MM-DD",
"description": "Validated exception description"
}
]
}
A valid output follows this structure:
{
"overview": "Three high-priority conflicts require planning review.",
"priorities": [
{
"exception_id": "EX-1234567890ABCDEF",
"risk": "high",
"summary": "The employee is allocated above validated weekly availability.",
"recommended_next_step": "Review allocation hours and qualified alternatives.",
"questions_for_planner": [
"Can part of the allocation move to another week?",
"Is another qualified employee available?"
]
}
],
"data_quality_notes": [
"One requirement does not have a named allocation."
]
}
Low-confidence handling is conservative. The implementation does not ask the model for a numeric confidence score because self-reported model confidence is not a dependable control. Any unknown exception ID, malformed JSON, unsupported risk value, or service failure causes rejection of the entire brief.
Benefits of the AI Enhancement
- Less planner time spent reading repetitive exception descriptions
- More consistent briefing structure across planning cycles
- Quicker identification of the conflicts that require discussion first
- Suggested questions that help managers prepare for the meeting
- Better handling of a growing queue of unstructured resolution notes
- A reviewable summary without changing validated planning records
These are AI-specific benefits. Capacity visibility, exception accuracy, status ownership, reminders, and reporting already come from the rule-based system.
What Remains Rule-Based or Human-Controlled
| Decision | Control | Reason |
|---|---|---|
| Capacity calculation | Deterministic script | Dates and hours require reproducible arithmetic |
| Duplicate detection | Exact keys and matching rules | Known identifiers should not be interpreted probabilistically |
| Leave deduction | Approved status and date rules | Availability must follow recorded approval evidence |
| Skill qualification | Validated skill table plus supervisor review | AI must not infer employee capability |
| Allocation confirmation | Planning manager | Staffing decisions affect delivery commitments and employees |
| High-conflict override | Operations director | Risk acceptance requires accountable human authority |
| Project priority | Leadership | Priority depends on commercial and operational context outside the model |
| Employee communication | Manager | Messages may require context, judgment, and appropriate tone |
| Final AI brief | Planning manager review | AI output may omit context or require correction |
Estimating the Additional Value of AI
The AI estimate uses 22 planning briefing cycles per month. Without AI, the planner spends approximately 15 minutes reviewing and drafting each exception summary. With AI, human review takes six minutes per briefing.
- AI correction rate: 15 percent of briefings
- Correction effort: 4 additional minutes
- AI service failure rate assumption: 3 percent
- Fallback effort after failure: 15 minutes
- AI usage allowance: $18 per month
- Loaded planning labor cost: $48 per hour
| Stage | Calculation | Monthly result |
|---|---|---|
| Core automation without AI | 22 × 15 minutes | 5.50 hours |
| AI-assisted human review | 22 × 6 minutes | 2.20 hours |
| Corrections | 22 × 15% × 4 minutes | 0.22 hours |
| Service-failure fallback | 22 × 3% × 15 minutes | 0.17 hours |
| Total with AI | 2.20 + 0.22 + 0.17 | 2.59 hours |
| Additional capacity | 5.50 – 2.59 | 2.91 hours |
| Labor value | 2.91 × $48 | $139.68 |
| Net additional value | $139.68 – $18 | $121.68 per month |
This estimate does not assume that AI eliminates review or errors. Its value depends on exception volume, briefing frequency, output quality, model cost, and the planner’s actual editing time.
Testing Checklist
Use sample data and test accounts before processing real employee or project information.
| Test | Expected result |
|---|---|
| Normal requirement submission | Requirement is saved, processed, expanded by week, and visible in demand reporting |
| Normal allocation submission | Capacity and demand are recalculated and the source becomes Processed |
| Missing required field | AppSheet blocks submission or creates a visible invalid-record exception |
| Invalid date range | Form validation blocks it or the script creates a high-severity exception |
| Invalid employee reference | Processing fails visibly without silently discarding the record |
| Exact duplicate allocation | Form blocks it or duplicate exception is generated |
| Duplicate webhook event | Second delivery returns duplicate success without repeating work |
| Failed webhook authentication | No workbook change occurs and an error response is returned |
| Expired or replaced credential | Failure is logged and processing resumes after configuration is corrected |
| Unavailable approver | Reminder routes to delegate or planning mailbox |
| Rejection | Reason is required and rejected hours are excluded |
| Reassignment | Old allocation is cancelled and new employee capacity is recalculated |
| Overdue exception | Daily reminder includes the item |
| High-severity escalation | Correct owner receives the notification and override remains restricted |
| Failed file upload | Record remains incomplete without a false evidence link |
| Failed notification | Exception does not receive a false Sent status |
| Unauthorized user | Restricted rows and actions are unavailable |
| Partial-week requirement | Hours are prorated using covered weekdays |
| Approved leave | Capacity is reduced in each affected week |
| Unapproved leave | Capacity remains unchanged |
| Skill mismatch | High-severity exception is created |
| Resolved conflict | Existing exception changes to Resolved rather than creating a duplicate |
| Malformed AI output | Brief is rejected and operational records remain unchanged |
| Inaccurate AI statement | Human reviewer rejects or edits the brief |
| AI service failure | Planner uses the ordinary exception queue |
| Successful completion | Source, event, outputs, notifications, and audit fields agree |
| Reporting | Dashboard totals reconcile to source and calculated tables |
| Retry behavior | Transient failures retry without duplicate exceptions or emails |
Ongoing Maintenance
| Frequency | Activity | Owner |
|---|---|---|
| Daily | Review failed executions, failed Events, Error rows, and unresolved high-severity exceptions | Planning system owner |
| Weekly | Reconcile project totals, review stale proposals, and sample notification delivery | Operations planning manager |
| Monthly | Review users, delegates, licensing allowance, automation volume, Gmail use, and optional AI cost | System owner and administrator |
| Quarterly | Review permissions, former-user removal, security filters, credential rotation, and retention | Google Workspace administrator |
| Quarterly | Sample AI briefs for unsupported statements, omissions, correction rate, and prohibited data | Planning manager |
| Quarterly | Test webhook fallback, duplicate handling, reminders, restore procedure, and manual reprocessing | Backup system owner |
| Twice yearly | Archive old resolved exceptions and completed events after approved retention review | System owner |
| Annually | Review data model, planning horizon, status definitions, role taxonomy, skill scale, and upgrade criteria | Operations leadership |
Every script, AppSheet expression, table field, role, and recovery procedure should be documented. Structural changes must be tested on a copy of the workbook before production deployment.
At least two people should know how to inspect Apps Script executions, disable triggers, update the web-app deployment, restore a workbook copy, and reprocess a failed record.
When to Move to Dedicated Software
The implementation should not be replaced merely because it uses low-code and spreadsheet components. It remains appropriate while volumes, calculations, controls, and maintenance are manageable.
Review dedicated resource-management, professional-services automation, manufacturing planning, workforce scheduling, or project portfolio software when several of these conditions appear:
- Hundreds of concurrent projects or thousands of active allocations
- Daily or shift-level scheduling rather than weekly capacity planning
- Complex calendars, overtime rules, union rules, or location-specific working patterns
- Multiple legal entities, countries, plants, or time zones
- Formal regulatory validation or immutable audit requirements
- Advanced skill certification, expiry, and competency workflows
- Real-time integration with HR, payroll, ERP, manufacturing, or project accounting systems
- Persistent API, spreadsheet-size, synchronization, or script-runtime constraints
- Increasing exception rates that require scheduling optimization algorithms
- Customer-facing portals or external staffing partners
- Offline planning requirements beyond the mobile platform’s practical limits
- Advanced forecasting, simulation, financial costing, or portfolio optimization
- Security requirements that prohibit the selected webhook architecture
- Excessive administrator effort or dependence on unsupported custom code
- A requirement for contractual vendor support and formal service commitments
A migration can preserve the data definitions developed here. Projects, employees, skills, requirements, allocations, scenarios, statuses, exceptions, and audit events provide a useful foundation for evaluating dedicated software.
Implementation Checklist
- Confirm planning volume, horizon, roles, skills, scenarios, and approval rules.
- Select AppSheet, Google Sheets, Apps Script, Gmail, and Drive responsibilities.
- Create the automation owner, backup owner, planning mailbox, and test users.
- Confirm licensing features and organizational webhook policy.
- Create the restricted Drive structure and planning workbook.
- Run the workbook setup script and verify exact headers.
- Load and validate employees, users, projects, skills, and baseline records.
- Configure AppSheet keys, references, enum values, forms, and validations.
- Apply security filters and role-based actions.
- Create baseline and scenario views.
- Configure Pending status updates and webhook payloads.
- Store spreadsheet ID, webhook secret, planning email, and optional AI credentials securely.
- Deploy and authorize the Apps Script web app.
- Install fallback-processing and reminder triggers.
- Verify field mappings between AppSheet, Sheets, Apps Script, and Gmail.
- Test capacity, leave, demand, skill, duplicate, and date calculations.
- Configure approval, rejection, return, delegation, and override actions.
- Create new-work, overdue, exception, utilization, gap, and failure views.
- Configure document permissions and optional skill-evidence handling.
- Run normal, failure, retry, security, and reconciliation tests with sample data.
- Pilot with a limited project group before full activation.
- Document rollback, recovery, support, and escalation procedures.
- Replace representative implementation and savings assumptions with measured figures.
- Add AI only after deterministic calculations and exception data are reliable.
- Require human review of every AI briefing.
- Assign primary and backup maintenance owners.
- Review volume, security, integration, audit, and dedicated-software upgrade criteria annually.
Get a FREE
Proof of Concept
& Consultation
No Cost, No Commitment!


