💼 Business problem
Customers hate guessing what shipped and what’s delayed. CS teams spend hours replying to “where’s my order?” emails after a partial shipment.
Goal: When an Item Fulfillment posts and it’s not the full order, automatically email the customer with:
- What shipped (qty, items, tracking)
- What’s pending/backordered (qty remaining, optional ETA)
- Friendly copy + links to their order/portal
🧠 Approach overview
- Trigger:
afterSubmit
on Item Fulfillment (IF) → typecreate
. - Logic: Load the Sales Order (SO) via
createdfrom
. For each SO line, comparequantity
vsquantityfulfilled
. If any line is partially fulfilled (fulfilled > 0 and < ordered) or any line remains 0 while others shipped → it’s a partial. - Action: Build a simple HTML email summarizing shipped vs. remaining lines. Include tracking pulled from the IF.
- Opt-in: Only notify when SO checkbox
custbody_notify_on_partial
is true (so ops can control).
🔧 Prerequisites
- SO body checkbox:
custbody_notify_on_partial
(Default = T if you want system-wide). - (Optional) SO line date field for ETA:
custcol_expected_ship_date
(Date). - (Optional) Email template file (HTML) in File Cabinet; or use inline HTML (below).
- Deployment role permissions: Read/Send Email, Load SO/IF, View tracking fields.
✅ User Event (SuiteScript 2.1) — Item Fulfillment afterSubmit
Copy/paste, deploy on Item Fulfillment.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
* Title: UE | Email customer on partial fulfillment
* Author: The NetSuite Pro
*/
define(['N/record','N/search','N/email','N/runtime','N/format','N/log','N/url'],
(record, search, email, runtime, format, log, url) => {
const SO_NOTIFY_FIELD = 'custbody_notify_on_partial';
const SO_LINE_ETA = 'custcol_expected_ship_date'; // optional
const afterSubmit = (ctx) => {
if (ctx.type !== ctx.UserEventType.CREATE) return;
try {
const ifRec = record.load({ type: record.Type.ITEM_FULFILLMENT, id: ctx.newRecord.id });
const soId = ifRec.getValue('createdfrom');
if (!soId) return;
const so = record.load({ type: record.Type.SALES_ORDER, id: soId });
const notify = !!so.getValue(SO_NOTIFY_FIELD);
if (!notify) return;
const customerId = so.getValue('entity');
const customerEmail = so.getValue('email'); // fallback to entity email; could also search for primary contact
// Build shipped lines (from the IF just created)
const shipped = [];
const ifLineCount = ifRec.getLineCount({ sublistId: 'item' });
for (let i = 0; i < ifLineCount; i++) {
const itemId = ifRec.getSublistValue({ sublistId:'item', fieldId:'item', line:i });
const itemName = ifRec.getSublistText({ sublistId:'item', fieldId:'item', line:i });
const qtyShip = Number(ifRec.getSublistValue({ sublistId:'item', fieldId:'quantity', line:i }) || 0);
if (qtyShip > 0) {
shipped.push({ itemId, itemName, qty: qtyShip });
}
}
// Compute remaining lines from the SO snapshot
const remaining = [];
const soLineCount = so.getLineCount({ sublistId: 'item' });
let anyShipped = shipped.length > 0;
let anyRemaining = false;
for (let i = 0; i < soLineCount; i++) {
const itemName = so.getSublistText({ sublistId:'item', fieldId:'item', line:i });
const qtyOrdered = Number(so.getSublistValue({ sublistId:'item', fieldId:'quantity', line:i }) || 0);
const qtyFulfilled = Number(so.getSublistValue({ sublistId:'item', fieldId:'quantityfulfilled', line:i }) || 0);
const eta = so.getSublistValue({ sublistId:'item', fieldId: SO_LINE_ETA, line:i });
const qtyRemain = Math.max(qtyOrdered - qtyFulfilled, 0);
if (qtyRemain > 0) {
anyRemaining = true;
remaining.push({
itemName,
qtyRemain,
eta: eta ? tryFormatDate(eta) : ''
});
}
}
// If not partial (i.e., shipped and nothing remaining), do nothing
const isPartial = anyShipped && anyRemaining;
if (!isPartial) return;
// Tracking (from IF packages) – optional
const trackingList = collectTracking(ifRec);
// Compose email HTML
const soTranId = so.getValue('tranid');
const subject = `Your order ${soTranId} has partially shipped`;
const html = buildEmailHtml({
soTranId,
shipped,
remaining,
trackingList
});
// Recipient: customer email or fall back to primary contact
if (!customerEmail) {
log.audit('No customer email on SO; skipping send', { soId, customerId });
return;
}
// Send
const author = runtime.getCurrentUser().id;
email.send({
author,
recipients: customerEmail,
subject,
body: html,
relatedRecords: { transactionId: soId }
});
log.audit('Partial fulfillment email sent', { soId, customerEmail, shippedCount: shipped.length, remainingCount: remaining.length });
} catch (e) {
log.error('Partial fulfillment UE error', e);
}
};
function collectTracking(ifRec) {
const tracking = [];
const pkgCount = ifRec.getLineCount({ sublistId: 'package' });
for (let i = 0; i < pkgCount; i++) {
const num = ifRec.getSublistValue({ sublistId:'package', fieldId:'packagetrackingnumber', line:i });
const carrier = ifRec.getSublistText({ sublistId:'package', fieldId:'packagecarrier', line:i });
if (num) tracking.push({ carrier, number: num });
}
return tracking;
}
function tryFormatDate(val) {
try {
return format.format({ value: val, type: format.Type.DATE });
} catch (_e) {
return String(val);
}
}
function buildEmailHtml({ soTranId, shipped, remaining, trackingList }) {
const css = `
<style>
.tbl{border-collapse:collapse;width:100%;}
.tbl th,.tbl td{border:1px solid #e5e5e5;padding:6px 8px;font-size:13px;}
.num{text-align:right;}
.muted{color:#666;}
h2{margin:0 0 8px 0;font-size:18px;}
h3{margin:16px 0 6px 0;font-size:15px;}
</style>`;
const shippedRows = shipped.map(s => `<tr><td>${escapeHtml(s.itemName)}</td><td class="num">${s.qty}</td></tr>`).join('');
const remainRows = remaining.map(r => `<tr><td>${escapeHtml(r.itemName)}</td><td class="num">${r.qtyRemain}</td><td>${r.eta || '<span class="muted">—</span>'}</td></tr>`).join('');
const trackRows = trackingList.length
? trackingList.map(t => `<div>${escapeHtml(t.carrier || 'Carrier')}: <b>${escapeHtml(t.number)}</b></div>`).join('')
: '<div class="muted">Tracking not available for all packages yet.</div>';
return `
${css}
<div>
<h2>Good news — part of your order has shipped!</h2>
<div>Order: <b>${escapeHtml(soTranId)}</b></div>
<h3>Shipped Items</h3>
<table class="tbl">
<tr><th>Item</th><th class="num">Qty Shipped</th></tr>
${shippedRows || '<tr><td colspan="2" class="muted">No items detected.</td></tr>'}
</table>
<h3>Items Still on the Way</h3>
<table class="tbl">
<tr><th>Item</th><th class="num">Qty Remaining</th><th>ETA</th></tr>
${remainRows || '<tr><td colspan="3" class="muted">None.</td></tr>'}
</table>
<h3>Tracking</h3>
${trackRows}
<p class="muted">You’ll receive another email once the remaining items ship. If you have questions, just reply to this message.</p>
</div>
`;
}
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
return { afterSubmit };
});
🧪 Testing checklist
- On a Sales Order, set Notify on Partial = T.
- Partially fulfill one or more lines (create IF) → email should send.
- Fulfill remaining lines → no email (full fulfillment).
- Add tracking on the IF → tracking shows in the email.
- Set ETAs on SO lines (optional) → appears in “Items Still on the Way”.
🌱 Optional: Daily “Backorder Digest” (Scheduled/MR)
Send one consolidated email per customer listing all open SO lines with qty remaining > 0 and optional ETA.
- Input: Saved Search on Sales Order with
status = Pending Fulfillment/Partially Fulfilled
and formulaquantity - quantityfulfilled > 0
. - Group by Customer and email once per day at 08:00.
🧠 Tips & gotchas
- Email recipient: If SO.email is blank, consider the primary contact email; or route to an internal team if missing.
- Edge cases: Drop-ship lines, kit components, or serialized items—validate fields in your account.
- Branding: Swap the inline HTML for a File Cabinet template if Marketing wants full control.
- Rate limits: Keep the email simple; avoid heavy rendering if you process hundreds per hour.
📌 Summary
Objective | Result |
---|---|
Proactively inform customers on partials | Automatic, clear emails with shipped vs. remaining |
Reduce support tickets | Fewer “where’s my order?” questions |
Improve CX | Include tracking + optional ETAs |
Leave a Reply