HandiRamp rents yard ramps to customers at ~$800–$4,000/month. The core operational challenge: ramps are physical assets at customer locations, and freight cost is the margin killer.
Why the tracking system exists: Knowing where every ramp is, in real time, is the entire business case. The BOL layer being designed will automate this. Today it's partially manual.
| System | Role | Owner | Status |
|---|---|---|---|
| Genius ERP | Physical asset register. Source of truth for serial numbers, Genius IDs, billing, invoicing. | Accounting / CS | ✅ Live |
| Zoho CRM | Rental contracts, customer relationships, fleet status tracking, automation. YR_Equipment module synced from Genius manually. | CS / Sales Management | ✅ Live |
| Google Sheet | Legacy fleet tracker. Being replaced by Zoho. Key limitation: location requires manual update; status not automated. | Manual (legacy) | ⚠ Legacy — being replaced |
YR Equipment lookup. Only Available ramps shown. Location proximity drives selection (directly feeds the freight math).Rental_Status → Checked Out. (Note: Genius ID copy to Ramp No. field was removed March 23, 2026 — process diagram may still show this, but it is not in the live function.)Rental Pickup Requested Date set → Rental Status = Rental Order Completed.
Original_Order. Copies all 4 YR_Equipment lookups. Strips freight/misc line items — leaves only monthly rental charge. Guard: if Original_Order already set (month 2+), YR3 exits immediately — no-op.Notification of Contract End on Initial Order → YR2 fires → Equipment = Available → 30-day window starts.
All pickup fields entered and maintained by Sales Manager only.
| Scenario | Pickup Type | Pickup Required By | YR4 Fires? | Notes |
|---|---|---|---|---|
| 1. Standard 30-Day | Standard 30-Day |
Notification date + 30 days | ⚡ Yes — at day 20 | Soft deadline. YR4 auto-escalates to Customer Hard Date 10 days before deadline. |
| 2. Customer Hard Date | Customer Hard Date |
Customer's firm date | No | Hard constraint — construction site, lease end, facility handoff. Rental Pickup Requested Date must be ≤ Pickup Required By. Missing this = customer dispute. |
| 3. Extended Stay | Extended Stay |
Blank — until Sales Manager sets a date | No | Customer not in a hurry. Ramp stays Checked Out. Cancellation is on record but no pickup urgency. Sales Manager sets Pickup Required By only when a date is eventually agreed. |
| Field | Purpose |
|---|---|
Notification of Contract End | Date customer formally notified cancellation. Triggers YR2 (ramp → Available). Always entered first. |
Pickup Type | Standard 30-Day / Customer Hard Date / Extended Stay. Drives urgency and how Pickup Required By is interpreted. |
Pickup Required By | Deadline for ramp removal. Standard = +30 days. Hard Date = customer's firm date. Extended Stay = blank until agreed. |
Rental Pickup Requested Date | Date freight pickup actually scheduled. Set when freight is booked. Used in formula fields (Days Overrun, Est Days Remaining) to track vs. deadline. |
| Field | Purpose |
|---|---|
YR_Equipment / _2 / _3 / _4 | Lookup to physical ramp — up to 4 slots for multi-ramp orders. Assignment triggers YR1. |
Rental_Order_Type | Initial Order / Monthly Rental Charge |
Rental_Start_Date | BOL delivery date — manual entry by CS |
Estimated_Rental_Months | Rep's forecast at booking |
Estimated_Return_Date | Calculated: Start Date + Est. Months (handles fractional months) |
Remaining_Rental_Days | Calculated: Est. Days − Days Elapsed. Freezes when pickup date set. Goes negative in overtime. |
Rental_Status | Auto-calculated formula — 5 categories (see below). NOT the same as Equipment Rental_Status. |
Cancellation_Notification_Received | Label: "Notification of Contract End." Triggers YR2 when populated. Triggers YR2b (reversal) when cleared. |
Contract_End_Date | Paired with cancellation notice |
Pickup_Type | Standard 30-Day / Customer Hard Date / Extended Stay |
Pickup_Required_By | Deadline for ramp removal |
Rental_Pickup_Requested_Date | Date freight actually scheduled → triggers Rental Order Completed status |
Original_Order | On Monthly Charge SOs only — lookup to Initial SO. Auto-set by YR3. |
| Status | Trigger Condition |
|---|---|
| 🟢 Long-Term Remaining | 30+ days to estimated return |
| 🟡 Nearing End | 1–30 days remaining — action: line up replacement customer |
| 🟠 Extended Past Estimate | Remaining days negative (overtime) — revenue upside |
| 🔵 Available – Pending Return | Notification of Contract End populated — ramp marketable, still at customer |
| ✅ Rental Order Completed | Rental Pickup Requested Date entered |
| Google Sheet Column | Zoho Location |
|---|---|
| Ramp ID (serial) | Equipment · Serial Number |
| Rep | Initial SO · Sales Order Owner |
| Status (Checked Out / Available) | Equipment · Rental Status (auto via workflow) |
| Check In Due On | Initial SO · Estimated Return Date (calculated) |
| Pictures / Description | Equipment · Item / Model |
| Location / City / State / Zip | Initial SO · Ship-To Address fields |
| Location Note | Initial SO · Description / Notes |
| Rental Amount | Initial SO · Line Item Grand Total |
| How Long Expected on Rent | Initial SO · Estimated Rental Months |
| Ramp Age (times rented) | Equipment · Related SOs count (derived) |
| NOTES | Initial SO · Notes |
| (no Google Sheet equivalent) | Initial SO · Notification of Contract End ← new in Zoho |
Key improvement: location always accurate (from SO Ship-To, not manual update). Status always current (workflow automation, not rep remembering to edit a cell).
| Field | Type | Notes |
|---|---|---|
Name / Serial Number | Text (record title) | Primary key. Source: Genius SerialNumber. |
Genius_ID | Integer | ERP asset link. Source: Genius export. |
Current_Customer | Text field (not a CRM lookup) | Customer name from Genius sync. Updated by periodic Genius export. NOT an Account lookup. |
Rental_Status | Picklist | Currently only: Available / Checked Out. Workflow-managed — Genius sync never touches this field. |
City / State | Text | Exist in Zoho. Currently unpopulated. |
Active_in_Genius | Boolean | Set False for Sunbelt and non-Genius records. |
Rental_Status is owned entirely by Zoho automation (YR1/YR2). Genius sync never overwrites it. Even if Genius shows a different status, the Zoho value stands.
| Rule | Why |
|---|---|
SerialNumber starts with "Sold to" | Sold units, not active rentals |
SerialNumber starts with "Billboard Rental" | Non-standard rental, excluded |
SerialNumber starts with "SUNBELT" | Sunbelt records — exist in Zoho as non-Genius, never overwrite or delete |
IsActive = False | Inactive/retired records |
Serial = "QMH NC" (Genius ID 31) | Placeholder, no real serial |
| Genius ID 147 | MST20-24060012 - Ramp entered in error — leave in Zoho as-is, never update |
| Zoho Name | Genius SerialNumber | Notes |
|---|---|---|
FT20-25020015 | FT20-2502015 | Genius has typo (missing zero) — Genius ID 198 |
MST20-25070028 (96 Wide) | MST20-25070028 | Zoho has ` (96 Wide)` suffix |
ITZ20-23021504 | ITZ20-23021504 also #56 | VLS Environmental pair — multi-ramp SO edge case |
ITZ20-23011496 | ITZ20-23011496 also #45 | VLS Environmental pair |
PSL-503654-Rental | PSL-503654 | Zoho has -Rental suffix — Genius ID 154 |
Genius_ID — if missing or wrongCurrent_Customer — if Genius has a newer customer
Rental_Status — workflow-driven inside ZohoName (Serial Number) — only Nathan renames| Action | Count | Detail |
|---|---|---|
| Customer updates | 5 | MST20-24060014, MST20-24100020, ITZ20-U250301, BRS20-25111060, ITZ20-25113890 |
| Genius_ID fixes | 2 | HT20-25051082 → 260, PSL-503654-Rental → 154 |
| Records deleted | 1 | Q20-U8434NU01 — not in Genius, phantom record |
| New records created | 0 | All Genius active records already in Zoho |
void automation.FunctionName() → void automation.FunctionName(String id) on save. When this happens, input.get("id","") returns "" and the function fails with "Value is empty" on .toLong(). Fix: use soId = id; (not input.get("id","")). All live functions already use this pattern.
get() on input = 2 args (key + default): input.get("id","")get() on API result = 1 arg: soRecord.get("YR_Equipment")map["key"] = syntax error in Deluge.get().get() = doesn't work — break into separate variables"id" key — call .toLong() before passing to update
| Rule | Workflow ID | Trigger | Condition | Action | Status |
|---|---|---|---|---|---|
| YR1 | 817882000391611091 | YR_Equipment edited on SO | Rental_Order_Type = Initial Order | Slot 1 ramp → Checked Out | ✅ Live |
| YR1b | live | YR_Equipment_2 edited | same | Slot 2 ramp → Checked Out | ✅ Live |
| YR1c | live | YR_Equipment_3 edited | same | Slot 3 ramp → Checked Out | ✅ Live |
| YR1d | live | YR_Equipment_4 edited | same | Slot 4 ramp → Checked Out | ✅ Live |
| YR2 | 817882000391611125 | Cancellation_Notification_Received → not empty | — | All 4 slots → Available | ✅ Live |
| YR2b | 817882000391611204 | Cancellation_Notification_Received → cleared | Pickup_Requested_Date is empty | All 4 slots → Checked Out (reversal guard) | ✅ Live |
| YR3 | 817882000391772018 | Rental_Order_Type → Monthly Rental Charge | — | Link Original_Order + copy 4 equipment slots + strip freight/misc | ✅ Live |
| YR4 | live | Scheduled — 10 days before Pickup Required By | Pickup_Type = Standard 30-Day | Auto-escalate to Customer Hard Date | ✅ Live |
void automation.YR_CheckedOut_OnAssignment(String id)
{
soId = id;
soRecord = zoho.crm.getRecordById("Sales_Orders",soId.toLong());
equipmentMap = soRecord.get("YR_Equipment");
if(equipmentMap == null) { return; }
equipmentId = equipmentMap.get("id");
if(equipmentId == null) { return; }
zoho.crm.update("YR_Equipment",equipmentId.toLong(),{"Rental_Status":"Checked Out"});
}
YR1b / YR1c / YR1d are identical — replace "YR_Equipment" with "YR_Equipment_2", "YR_Equipment_3", "YR_Equipment_4" respectively.
void automation.YR_Available_OnCancellation(String id)
{
soId = id;
soRecord = zoho.crm.getRecordById("Sales_Orders",soId.toLong());
equipmentFields = list();
equipmentFields.add("YR_Equipment");
equipmentFields.add("YR_Equipment_2");
equipmentFields.add("YR_Equipment_3");
equipmentFields.add("YR_Equipment_4");
for each fieldName in equipmentFields
{
equipmentMap = soRecord.get(fieldName);
if(equipmentMap != null)
{
equipmentId = equipmentMap.get("id");
if(equipmentId != null)
{
zoho.crm.update("YR_Equipment",equipmentId.toLong(),{"Rental_Status":"Available"});
}
}
}
}
void automation.YR_ReversalGuard(String id)
{
soId = id;
soRecord = zoho.crm.getRecordById("Sales_Orders",soId.toLong());
equipmentFields = list();
equipmentFields.add("YR_Equipment");
equipmentFields.add("YR_Equipment_2");
equipmentFields.add("YR_Equipment_3");
equipmentFields.add("YR_Equipment_4");
for each fieldName in equipmentFields
{
equipmentMap = soRecord.get(fieldName);
if(equipmentMap != null)
{
equipmentId = equipmentMap.get("id");
if(equipmentId != null)
{
zoho.crm.update("YR_Equipment",equipmentId.toLong(),{"Rental_Status":"Checked Out"});
}
}
}
}
void automation.YR3_FollowUp_AutoPopulate(String id)
{
soId = id;
soRecord = zoho.crm.getRecordById("Sales_Orders",soId.toLong());
existingOriginal = soRecord.get("Original_Order");
if(existingOriginal != null) { return; } // Month 2+ guard — exit immediately
accountMap = soRecord.get("Account_Name");
if(accountMap == null) { return; }
accountName = accountMap.get("name");
if(accountName == null || accountName.toString() == "") { return; }
searchCriteria = "(Rental_Order_Type:equals:Initial Order)AND(Account_Name:equals:" + accountName + ")";
initialSOs = zoho.crm.searchRecords("Sales_Orders",searchCriteria,1,1);
if(initialSOs == null || initialSOs.size() == 0) { return; }
initialSOId = initialSOs.get(0).get("id");
initialRecord = zoho.crm.getRecordById("Sales_Orders",initialSOId.toLong());
updateMap = Map();
updateMap.put("Original_Order",{"id":initialSOId});
equipmentFields = list();
equipmentFields.add("YR_Equipment");
equipmentFields.add("YR_Equipment_2");
equipmentFields.add("YR_Equipment_3");
equipmentFields.add("YR_Equipment_4");
for each fieldName in equipmentFields
{
equipmentMap = initialRecord.get(fieldName);
if(equipmentMap != null)
{
equipmentId = equipmentMap.get("id");
if(equipmentId != null)
{
updateMap.put(fieldName,{"id":equipmentId});
}
}
}
allProducts = soRecord.get("Product_Details");
if(allProducts != null)
{
keepProducts = list();
for each product in allProducts
{
productName = product.get("product").get("name").toString().toLowerCase();
if(!productName.contains("freight") && !productName.contains("misc"))
{
keepProducts.add(product);
}
}
updateMap.put("Product_Details",keepProducts);
}
zoho.crm.updateRecord("Sales_Orders",soId.toLong(),updateMap);
}
YR4 is a scheduled workflow rule (not a custom function) — no Deluge code. Fires 10 days before Pickup Required By when Pickup Type = Standard 30-Day. Field update action only.
| Date | Change |
|---|---|
| 2026-03-17 | YR_Serial_Number text field removed from Sales Orders. Serial now surfaces only via YR_Equipment lookup record name. Do not re-add YR_Serial_Number references to any function. |
| 2026-03-23 | All 4 functions updated to 4-slot support. Ramp_No copy logic removed from YR1. YR1b/YR1c/YR1d added. |
| Metric | Value |
|---|---|
| Median months on rent | 4.7 months |
| Longest active rental | M20-1250 at PSC (Visalia, CA) — 91.8 months, $800/mo since Aug 2018 |
| Known data quality issue | ⚠ Duplicate Serial ITZ20-24061559 appears on two simultaneous active SOs — equipment swap orphan bug in production |
21 Available / 33 Checked Out at time of snapshot.
| Serial # | Location | State | Rep | Days to Hub Deadline | Notes |
|---|---|---|---|---|---|
| DB-HDC6060 001 | Kenosha, WI | WI | Bill D | At Hub | NEW |
| ITZ20-25113890 | Yorkville, WI | WI | Bill | At Hub | Chicago IL → New Stock |
| ITZ20-23081529 | Yorkville, WI | WI | Bill | At Hub | Imlay City MI → New Stock |
| MST20-84-30NU | Yorkville, WI | WI | — | At Hub | — |
| MST20-24070016 | Denton, NC | NC | — | At Hub | Via Floral Park NY, Schaefferstown PA |
| BRS20-25101057 | Rio Vista, TX | TX | — | At Hub | Framework Events → Bulverde TX |
| BRS20-25111060 | Rio Vista, TX | TX | — | At Hub | Pryor OK → New Stock |
| ITZBZ20-2110 | Rio Vista, TX | TX | — | At Hub | Pryor OK → Houston TX |
| Q20-U8434NU01 | Glendale, AZ | AZ | — | At Hub | — |
| FT20-24080005 | Glendale, AZ | AZ | — | At Hub | New Ramp |
| BRS20-U2571049 | Glendale, AZ | AZ | — | At Hub | Hyrox NA → Carrollton TX |
| FT20-24120014 | Glendale, AZ | AZ | — | At Hub | Hyrox NA → Santa Fe NM |
| FT20-25070026 | Glendale, AZ | AZ | — | At Hub | Hyrox NA → Las Vegas NV |
| FT20-25070025 | Glendale, AZ | AZ | — | At Hub | Glendale AZ → Las Vegas NV |
| PSL-503654-Rental | Platte, SD | SD | Bill | 24 days | Lift Rental — 10+ months |
| MST20-23070002 | Cookeville, TN | TN | — | 21 days | Denton NC → Atlanta GA |
| MST20-25070028 | Carrollton, TX | TX | — | 19 days | 96" Wide — Sturtevant WI → Mt Clemens MI |
| ITZ20-U250301 | McKinney, TX | TX | — | 17 days | Rio Vista TX → Houston TX |
| MST20-24100020 | Kingsport, TN | TN | — | 14 days | → Statesville NC |
| ITZ20-24121577 | Ormond Beach, FL | FL | — | 11 days | Denton NC → Ashland OH |
| ITZ20-24121575 | Siloam Springs, AR | AR | — | 6 days ⚠ | Locust Grove OK |
| Rep | Created | Won | Open |
|---|---|---|---|
| Mark Larson | 32 | 7 | 24 |
| Bill Dorn | 30 | 5 | 21 |
| John Rodriguez | 17 | 6 | 11 |
| Chris Hamilton | 12 | 1 | 10 |
| Bill Klaus | 10 | 1 | 9 |
| Alex Disch | 1 | 0 | 1 |
| Channel | Open Opps | Closed Orders |
|---|---|---|
| Phone Inbound | 42 (59%) | 9 |
| Web Form | 13 (18%) | — |
| Chat | 8 (11%) | 1 |
| — | 1 | |
| Not attributed | 8 | 17 |
57% of closed orders have no linked contact with a channel set in CRM — attribution gap, not confirmed untracked.
notes/zoho/yr-rental-bol-module.md (backup) and projects/yr-equipment-bol-tracking.md. Nothing has been built in Zoho yet.
| Field | Type | Notes |
|---|---|---|
Rental_Status | Picklist — expand | Add Reserved and In Transit to existing Available / Checked Out |
Location_Type | Picklist — new | Hub / At Customer / At Customer – Pending Return / In Transit |
Current_Hub | Picklist — new | Kenosha WI / Denton NC / Glendale AZ / Rio Vista TX |
Current_SO | SO lookup — new | Active SO the ramp is assigned to. Replaces Current_Customer text field. SO surfaces customer, contact, and ship-to cleanly. |
Last_Move_Date | Date — new | Auto-updated on BOL status changes |
In_Service_Date | Date — new | Auto from first BOL delivery — only if currently blank; manual entry wins |
Current_City / Current_State | Text — already exist | Exist in Zoho, currently unpopulated. Will be auto-updated from BOL. |
| Field | Type | Required | Notes |
|---|---|---|---|
Equipment | YR_Equipment lookup | ✅ | Links BOL to the ramp |
Sales_Order | SO lookup | ✅ for deliveries/returns | Optional for hub transfers |
BOL_Type | Picklist | ✅ | Delivery to Customer / Return from Customer / Hub Transfer / Other |
Status | Picklist | ✅ | Created / In Transit / Delivered / Cancelled |
Carrier | Text | ✅ | Free text — multi-vendor, no single carrier dependency |
Carrier_Reference | Text | PRO # or carrier order # | |
BOL_Number | Text | Physical BOL number | |
From_Location_Name | Text | Hub name or customer name | |
From_City / From_State | Text | ||
To_Location_Name | Text | Hub name or customer name | |
To_City / To_State | Text | ||
Ship_Date | Date | ||
Delivered_Date | Date | ||
Freight_Cost | Currency | Enables freight cost reporting per rental | |
BOL_Attachment | File | Physical BOL scan/photo | |
Notes | Text area |
Accounts → Sales_Orders (many per account) → YR_Equipment (lookup on SO, up to 4 slots) → Bills_of_Lading (many per equipment) → Sales_Order lookup (ties BOL to specific rental)
| Event | Rental_Status | Location_Type |
|---|---|---|
| Assigned to SO by CS/Sales Mgr | Reserved | (unchanged) |
| BOL created + marked In Transit | In Transit | In Transit |
| BOL Delivered — Delivery to Customer | Checked Out | At Customer |
| BOL Delivered — Return from Customer | In Transit | In Transit |
| BOL Delivered — Hub Transfer | Available | Hub |
| Cancellation notification received (YR2) | Available | At Customer – Pending Return |
| Situation on Original Ramp | Action |
|---|---|
| No BOL exists | Automation reverts original to Available, clears Current_SO. New ramp gets Reserved. |
| BOL exists, Status = Created | Swap permitted. CS must manually cancel/void the BOL on original ramp. |
| BOL Status = In Transit or Delivered | Swap blocked. CS must resolve BOL manually before reassignment. |
| # | Step |
|---|---|
| 1 | Add new fields to YR_Equipment in Zoho (expand Rental_Status, add Location_Type, Current_Hub, Current_SO, Last_Move_Date, In_Service_Date) |
| 2 | Create Bills_of_Lading custom module with full field spec |
| 3 | Update YR1–YR1d to set Reserved (not Checked Out) on assignment + fix equipment swap orphan bug |
| 4 | Build BOL-triggered automations (In Transit / Delivered / Hub Transfer status transitions) |
| 5 | Update YR2 to also clear Current_SO + set Location_Type = At Customer – Pending Return |
| 6 | Build Available Ramps dashboard (filter: Rental_Status = Available, show Current_Hub and location) |
memory/people/alexis-whelpley.md — waiting for her to fill in.
ITZ20-24061559 appears on two simultaneous active SOs in the April 16, 2026 snapshot — flagged as "Duplicate Serial" in the dashboard.YR_Serial_Number text field was removed from Sales Orders on March 17, 2026. Serial number now surfaces only via the YR_Equipment lookup record name. Some older function versions (pre-March 23) still reference this field. Do not re-add YR_Serial_Number references to any function.
input.get("YR_Equipment_before","") or similar). Required for the equipment swap orphan fix.| Item | Status |
|---|---|
| YR nightly reports cron (June 23 1:00 AM failure) | Fix applied in dream cycle — next run June 24 1:00 AM |
| Alexis Whelpley profile | Skeleton created — waiting for AW to fill in |
| Initial orders script second live run verification | Watch rentals_count in yr-reports-log.json after June 24 run |