πΌ Business problem
SDRs spend time manually routing new leads/customers to the right rep by country/state/postal code. Mistakes slow follow-ups. Weβll auto-assign the salesrep at creation (and on edits) using maintainable, non-code rules.
π§ Approach
- User Event (beforeSubmit) on Lead/Prospect/Customer.
- Read Ship To (fallback to Bill To) β
country
,state
,zip
. - Match against a JSON ruleset (script parameter) or an optional Custom Record map.
- Set
salesrep
to the repβs employee internal ID. - Optional role bypass lets managers override manually.
π§ Parameters (Deployment)
custscript_terr_rules_json
(Long Text) β JSON rules (see below)custscript_terr_bypass_roles
(CSV) β role IDs that skip auto-assigncustscript_terr_apply_on_edit
(Checkbox) β reassign on edit if rep is empty
Example rules JSON
[
{ "country":"CA", "state":"ON", "zip_from":"", "zip_to":"", "salesrep":"1234", "note":"Canada - Ontario" },
{ "country":"CA", "state":"BC", "zip_from":"", "zip_to":"", "salesrep":"1235", "note":"Canada - British Columbia" },
{ "country":"US", "state":"CA", "zip_from":"90000","zip_to":"96162","salesrep":"2001", "note":"US - California zips" },
{ "country":"US", "state":"TX", "zip_from":"", "zip_to":"", "salesrep":"2002", "note":"US - Texas any zip" },
{ "country":"GB", "state":"", "zip_from":"", "zip_to":"", "salesrep":"3001", "note":"UK all" }
]
Empty
zip_from/zip_to
means βany zipβ in that state/country. First match wins.
π§© User Event (SuiteScript 2.1)
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Title: UE | Auto-Assign Sales Rep by Territory
* Author: The NetSuite Pro
*/
define(['N/runtime','N/log'], (runtime, log) => {
const P_RULES = 'custscript_terr_rules_json';
const P_BYPASS = 'custscript_terr_bypass_roles';
const P_APPLY_ON_EDIT = 'custscript_terr_apply_on_edit';
const beforeSubmit = (ctx) => {
const rec = ctx.newRecord;
// Only on entity: lead/prospect/customer
if (!['lead','prospect','customer'].includes(rec.type)) return;
const isCreateLike = [ctx.UserEventType.CREATE, ctx.UserEventType.COPY, ctx.UserEventType.XEDIT].includes(ctx.type);
const isEdit = ctx.type === ctx.UserEventType.EDIT;
// Role bypass?
const bypassRoles = csv(runtime.getCurrentScript().getParameter({ name: P_BYPASS }));
if (bypassRoles.includes(String(runtime.getCurrentUser().role))) return;
// Should we run?
const applyOnEdit = runtime.getCurrentScript().getParameter({ name: P_APPLY_ON_EDIT }) === 'T';
const currentRep = safeGet(rec, 'salesrep');
if (isEdit && !applyOnEdit && currentRep) return;
try {
const geo = readGeo(rec); // {country, state, zip}
if (!geo.country) return;
const rules = loadRules();
const match = findMatch(rules, geo);
if (!match || !match.salesrep) return;
// Only set if empty or we're allowed to overwrite on edit
if (!currentRep || applyOnEdit) {
rec.setValue({ fieldId: 'salesrep', value: Number(match.salesrep) });
// Optional audit note if you keep a field like custentity_assignment_note
// safeSet(rec, 'custentity_assignment_note', `Auto-assigned to ${match.salesrep} (${match.note || ''})`);
}
} catch (e) {
log.error('Territory assign error', e);
}
};
// ---------- helpers ----------
function readGeo(rec){
// Prefer Ship To; fall back to Bill To; final fallback to entity fields
const shipCountry = safeGet(rec, 'shipcountry') || safeGet(rec, 'custentity_ship_country');
const shipState = safeGet(rec, 'shipstate') || safeGet(rec, 'custentity_ship_state');
const shipZip = (safeGet(rec, 'shipzip') || '').toString();
let country = (shipCountry || safeGet(rec, 'country') || '').toString().toUpperCase();
let state = (shipState || safeGet(rec, 'state') || '').toString().toUpperCase();
let zip = shipZip.replace(/\s+/g,'');
return { country, state, zip };
}
function loadRules(){
try {
const json = runtime.getCurrentScript().getParameter({ name: P_RULES }) || '[]';
const arr = JSON.parse(json);
return Array.isArray(arr) ? arr : [];
} catch (_e) { return []; }
}
function findMatch(rules, geo){
const c = geo.country, s = geo.state, z = onlyDigits(geo.zip);
// 1) country+state+zip range
let r = rules.find(x => eq(x.country,c) && eq(x.state,s) && inZipRange(x, z));
if (r) return r;
// 2) country+state (any zip)
r = rules.find(x => eq(x.country,c) && eq(x.state,s) && empty(x.zip_from) && empty(x.zip_to));
if (r) return r;
// 3) country-only default
r = rules.find(x => eq(x.country,c) && empty(x.state));
return r || null;
}
function inZipRange(rule, z){
const from = onlyDigits(rule.zip_from);
const to = onlyDigits(rule.zip_to);
if (!from && !to) return true; // no zip constraint
if (!z) return false;
if (from && to) return z >= from && z <= to;
if (from && !to) return z >= from;
if (!from && to) return z <= to;
return false;
}
function onlyDigits(s){ s = (s||'').toString(); const m = s.match(/\d+/g); return m ? m.join('') : ''; }
function eq(a,b){ return String(a||'').toUpperCase() === String(b||'').toUpperCase(); }
function empty(v){ return v==null || String(v).trim()===''; }
function safeGet(rec, fieldId){ try { return rec.getValue({ fieldId }); } catch(_e){ return null; } }
function csv(s){ return (s||'').split(',').map(x=>x.trim()).filter(Boolean); }
return { beforeSubmit };
});
β Testing checklist
- Add rules JSON (use your employee internal IDs).
- Create a Lead in CA / ON β rep auto-assigned to Ontario rep.
- Create a Customer in US / CA with ZIP 94016 β assigned to the CA ZIP range rep.
- Edit an existing record with rep blank β assignment runs (if
apply_on_edit = T
). - Login as a bypass role β rep remains unchanged.
π± Enhancements
- Custom Record Map: Instead of JSON, point to
customrecord_territory_map
with fields (country/state/zip_from/zip_to/salesrep). Load once and cache. - Round-Robin per territory: When a territory has multiple reps, pick by modulo on customer internal ID or last digit of ZIP.
- Channel ownership: If
leadsource = Partner
, set partner instead of (or in addition to)salesrep
. - Reassignment lock: If
custentity_rep_locked = T
, skip changes even on edit. - Geo cleanup: Normalize Canadian postcodes by first 3 chars (FSA) and route by FSA instead of full code.
π Summary
Objective | Result |
---|---|
Route leads/customers instantly | Rep auto-assigned on save |
Maintain rules without code | JSON/custom record mapping |
Avoid overwrites | Role bypass + edit behavior flag |