- Post History
- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
an hour ago
SPM Financials: Fix Cost Plan / Actual Cost mismatches with dry-run
Problem statement
A common ServiceNow SPM financials issue is when a project or demand shows incorrect or zero actual costs in the Financials tab / Portfolio Planning Workspace, even though fm_expense_line records exist.
Typical symptoms:
cost_plan.total_actual_costdoes not equal the sum of processedfm_expense_lineamounts.pm_project.work_cost/dmn_demand.work_costdoes not equal the sum ofcost_plan_breakdownrows wherebreakdown_type = task.cost_plan_breakdownrows have stalebreakdown_type = requirementactuals or duplicated task-level rows.- Task-level planned cost breakdowns are missing or out of sync with requirement-level planned cost.
This script rebuilds task-level planned and actual cost rollups for a project or a demand, using existing breakdown_type = requirement rows as the source of truth.
Root cause
The SPM financial rollup chain is split into two pipelines:
Planned cost
cost_plan
-> (existing) cost_plan_breakdown (breakdown_type = requirement) with planned cost
-> PPMCostRollupManager
-> cost_plan_breakdown (breakdown_type = task) with planned cost
-> task planned totals
Actual cost
fm_expense_line (state = processed)
-> PPMFundManager.updateActualsForTask() / updateActualsForCostPlan()
-> cost_plan_breakdown (breakdown_type = requirement) with actuals
-> cost_plan_breakdown (breakdown_type = task) with actuals
-> ExpenseLinesHelper.setTaskActualCosts()
-> pm_project.work_cost / dmn_demand.work_cost
The mismatch usually happens when one of those layers is updated and the downstream layers are not refreshed, for example:
- Custom cost types,
source_id/source_tablechanges, ortop_taskchanges. - Duplicate
cost_plan_breakdownrows from past data corruption / partial rollbacks. - Fiscal period changes without rebuilding breakdowns.
- Processed expense lines are deleted.
- Expense lines are imported.
- Processed expense lines are reparented with a different cost plan.
- The amount on a processed expense line is changed.
Any of the above can leave the cost_plan_breakdown task- and requirement-level rows out of sync with the expense-line source of truth. The script below rebuilds those breakdown layers.
Before you run this script
- Back up the affected project(s) / demand(s). The script deletes
breakdown_type = taskcost_plan_breakdownrows and rewritesactualonbreakdown_type = requirementrows. - Task-type breakdowns are deleted and recreated. Existing
breakdown_type = taskrows are removed, then regenerated from thebreakdown_type = requirementrows (planned cost) and fromfm_expense_linerecords (actual cost). Any manual edits made directly to task-type breakdown rows will be lost. - Test in a non-production instance first.
- The script is intended to be run as a Fix Script or in Scripts - Background by an admin user.
- The script is a data-correction workaround, not a product code fix. If the symptom is reproducible on a clean instance, open a new PRB / case.
Configuration
Edit the variables at the top of the script:
| Variable | Purpose |
|---|---|
DRY_RUN |
true = only logs what would be changed; false = applies the fix. |
TABLE |
pm_project or dmn_demand. |
SYS_IDS |
Array of affected sys_id values. Keep the list small. |
How to use
- Copy the script below into Scripts - Background or a Fix Script.
- Set
DRY_RUN = trueandSYS_IDSto the affected project/demandsys_idvalues. - Run the script and read the
gs.infooutput. - If the preview looks correct, set
DRY_RUN = falseand run again.
The script
var DRY_RUN = true;
var TABLE = 'pm_project'; // use 'dmn_demand' for demands
var SYS_IDS = ['35c600505301561042c7730330e5e6c8']; // replace with affected sys_ids
function resetRequirementTypeActualsToZero(sys_id) {
gs.info('Zeroing requirement breakdown actuals for task ' + sys_id);
var grBreakdown = new GlideRecord('cost_plan_breakdown');
grBreakdown.addQuery('breakdown_type', 'requirement');
grBreakdown.addQuery('task', sys_id);
grBreakdown.query();
while (grBreakdown.next()) {
grBreakdown.setValue('actual', 0);
grBreakdown.update();
}
}
function resetTaskBreakdownsCost(sys_id, table, taskRef) {
var gr = new GlideAggregate('cost_plan_breakdown');
gr.addQuery('breakdown_type', 'requirement');
gr.addNotNullQuery('cost_plan');
gr.addQuery('task', sys_id);
gr.groupBy('fiscal_period');
gr.groupBy('expense_type');
gr.query();
while (gr.next()) {
var util = new PPMCostRollupManager(gr.getValue('fiscal_period'), gr.getValue('expense_type'));
if (table == 'pm_project') {
util.updateProjectEstimatedCostByFiscalPeriod(taskRef.getUniqueValue());
} else if (table == 'dmn_demand') {
util.updateDemandEstimatedCostByFiscalPeriod(taskRef.getUniqueValue());
}
}
}
function resetTaskBreakdownsActual(sys_id) {
var gr = new GlideRecord('fm_expense_line');
gr.addQuery('task', sys_id);
gr.query();
while (gr.next()) {
var fundManager = new PPMFundManager();
if (!gr.cost_plan.nil()) {
fundManager.updateActualsForCostPlan(gr);
}
fundManager.updateActualsForTask(gr);
}
}
function updateTaskAndCpRecordActuals(taskId) {
var expenseLinesGr = new GlideRecord('fm_expense_line');
expenseLinesGr.addQuery('task', taskId);
expenseLinesGr.addQuery('state', 'processed');
expenseLinesGr.query();
while (expenseLinesGr.next()) {
try {
var helper = new ExpenseLinesHelper(expenseLinesGr);
helper.setTaskActualCosts();
var costPlan = new CostPlan(expenseLinesGr.cost_plan.getRefRecord());
var updated = costPlan.updateTotalActualCost();
if (updated) costPlan.update();
} catch (e) {
gs.error('Error updating task actuals for ' + taskId + ': ' + (e.message || e));
}
}
}
function resetTaskBreakdowns(sys_id, table) {
var taskRef = new GlideRecord(table);
if (!taskRef.get(sys_id)) {
gs.warn('Record not found: ' + table + ' ' + sys_id);
return;
}
if (DRY_RUN) {
var taskBreakdowns = new GlideRecord('cost_plan_breakdown');
taskBreakdowns.addQuery('breakdown_type', 'task');
taskBreakdowns.addQuery('task', sys_id);
taskBreakdowns.query();
var reqBreakdowns = new GlideRecord('cost_plan_breakdown');
reqBreakdowns.addQuery('breakdown_type', 'requirement');
reqBreakdowns.addQuery('task', sys_id);
reqBreakdowns.query();
var expenseLines = new GlideRecord('fm_expense_line');
expenseLines.addQuery('task', sys_id);
expenseLines.query();
gs.info('[DRY RUN] Would reset planned/actual breakdowns for ' + table + ' ' + sys_id +
' (task breakdowns: ' + taskBreakdowns.getRowCount() +
', requirement breakdowns: ' + reqBreakdowns.getRowCount() +
', expense lines: ' + expenseLines.getRowCount() + ')');
return;
}
resetRequirementTypeActualsToZero(sys_id);
var grDel = new GlideRecord('cost_plan_breakdown');
grDel.addQuery('breakdown_type', 'task');
grDel.addQuery('task', sys_id);
grDel.deleteMultiple();
resetTaskBreakdownsCost(sys_id, table, taskRef);
resetTaskBreakdownsActual(sys_id);
updateTaskAndCpRecordActuals(sys_id);
}
for (var i = 0; i < SYS_IDS.length; i++) {
resetTaskBreakdowns(SYS_IDS[i], TABLE);
}
Important notes
- Dry run first. Never run with
DRY_RUN = falseon the first attempt. - Task-type breakdowns are deleted and recreated. Existing
breakdown_type = taskcost_plan_breakdownrows are removed, then regenerated from the requirement-level planned cost and from the processedfm_expense_lineactuals. Any direct manual changes to task-type breakdown rows will be lost. - Requirement breakdowns are the source of truth. This script does not recreate
breakdown_type = requirementrows. It only rolls up from existing requirement breakdowns intobreakdown_type = taskbreakdowns. - Actual fix zeros and rebuilds requirement actuals. The script sets
cost_plan_breakdown.actualto0for all requirement rows, then reprocesses everyfm_expense_linethroughPPMFundManager. This makes the requirement-level actuals match the expense lines. - Scope. The script operates on a single
pm_projectordmn_demandrecord. If child project tasks also have their own expense lines, run the script for each childsys_idor adjust the query to include descendants.
Related cases and PRBs
These cases describe the same or related symptoms:
CSTASK1543676— Actual costs not shown in portfolio planning workspace.CSTASK1524920— Expense lines and cost plans out of sync.CSTASK1493068/PRB2003945/KB1318242— Duplicate cost plan breakdowns.CSTASK1541990— Difference between project actual cost and cost plan actual cost.CSTASK1532786— Expense lines add up to 0 but project actual cost is $0.02.CSTASK1539415— Expense line associated with generic resource internal OPEX instead of role-based.CSTASK1545300— Inconsistent total planned cost rollup from cost plans to program.CSTASK1361083— Total planned cost on program greater than cost plans.CSTASK1537249— Duplicate cost plan breakdown issue not resolved.CSTASK1496364— Project cost plan migrated as internal after resource plan migration.CSTASK1545115— Duplicate investment cost plan breakdowns.CSTASK1545092— Budget decreasing because of negative expense line.