AI Audit Freeze Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Implement POST /audit/transformers/:id/freeze — the final step of the AI Audit flow that commits AI audit suggestions to real collections (transformer, installation, audit-summaries).
Architecture: Reads the staging AI audit data, applies user overrides (diffs), computes final audit metrics for the target TC, writes changes to real collections, reaudits source TCs that lost installations, updates adjacent month loss percentages, and cascades audit summaries. Reuses existing computeAuditFields for source TC reaudits and recomputeAuditSummaries for the 6-level cascade.
Tech Stack: Fastify v5 (ESM), Mongoose, MongoDB Atlas, existing audit helpers
Task 0: Create the freezeAiAudit controller
Files:
- Create:
api/src/entities/audit/controller/freezeAiAudit.js
Step 1: Create the controller file with all business logic
This is the most write-heavy endpoint in the app. The controller follows 8 steps from the LLD:
- Validate & fetch ai-audit-result (check not frozen)
- Fetch transformer, AE section validation
- Fetch all ai-audit-installation docs
- Apply user overrides (merge diffs with staging data)
- Compute final audit metrics for target TC (with consumption overrides for remarked installations)
- Write changes to real collections (target TC, installations, source TCs, adjacent months, audit summaries)
- Mark ai-audit-result as frozen
- Return response
"use strict";
import { aiAuditResultModel } from "../../../../../common/src/schemas/aiAuditResult.model.js";
import { aiAuditInstallationModel } from "../../../../../common/src/schemas/aiAuditInstallation.model.js";
import { transformerModel } from "../../../../../common/src/schemas/transformer.model.js";
import { installationModel } from "../../../../../common/src/schemas/installation.model.js";
import { baseUserModel } from "../../../../../common/src/schemas/user.model.js";
import {
DatabaseError,
ForbiddenError,
ResourceUnavailableError,
ValidationError,
} from "../../../utils/customErrorInterfaces.js";
import { responseSerializer } from "../../../utils/responseSerializationSchema.js";
import { computeAuditFields } from "../helpers/computeAuditFields.js";
import { recomputeAuditSummaries } from "../helpers/recomputeAuditSummaries.js";
const databaseServerErrorCatchBlockFunction = (error) => {
console.error(error);
throw new DatabaseError({
userMessage: "Unexpected error while freezing AI audit",
sourceMessage: error.message,
name: error.constructor.name,
});
};
// ──── Helpers ────
function getNextMonth(month) {
const [year, mon] = month.split("-").map(Number);
return mon === 12
? `${year + 1}-01`
: `${year}-${String(mon + 1).padStart(2, "0")}`;
}
function getEffectiveConsumption(inst) {
if (inst.hasRemark && inst.finalUseAvgConsumption && inst.avgConsumption6Months > 0) {
return inst.avgConsumption6Months;
}
return inst.consumption || 0;
}
// ──── Main logic ────
async function freezeAiAudit(routeType, requestUser, params, body) {
try {
const { id } = params;
const { month, overrides = [] } = body;
// Step 1: Validate & fetch ai-audit-result
const aiAuditResult = await aiAuditResultModel
.findOne({ tcId: id, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (!aiAuditResult) {
throw new ResourceUnavailableError({
userMessage: "AI audit result not found",
sourceMessage: `AI audit result with tcId=${id}, month=${month} not found`,
});
}
if (aiAuditResult.frozen) {
throw new ValidationError({
userMessage: "AI audit already frozen",
sourceMessage: `AI audit result for tcId=${id}, month=${month} is already frozen`,
name: "AlreadyFrozenError",
});
}
// Step 2: Fetch transformer + AE section validation
const transformer = await transformerModel
.findOne({ id, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (!transformer) {
throw new ResourceUnavailableError({
userMessage: "Transformer not found",
sourceMessage: `Transformer with id=${id}, month=${month} not found`,
});
}
if (routeType === "ae") {
const user = await baseUserModel
.findOne({ userId: requestUser.userId }, { location: 1 })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (!user) {
throw new ResourceUnavailableError({
userMessage: "User not found",
sourceMessage: `User with userId ${requestUser.userId} not found`,
});
}
if (transformer.location.sectionCode !== user.location.sectionCode) {
throw new ForbiddenError({
userMessage: "TC does not belong to your assigned section",
sourceMessage: `TC section ${transformer.location.sectionCode} does not match user section ${user.location.sectionCode}`,
});
}
}
// Step 3: Fetch all ai-audit-installation docs
const aiAuditResultId = aiAuditResult._id.toString();
const aiInstallations = await aiAuditInstallationModel
.find({ aiAuditResultId })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
// Step 4: Apply user overrides
const overrideMap = new Map();
for (const override of overrides) {
overrideMap.set(override.accountId, override);
}
for (const inst of aiInstallations) {
const override = overrideMap.get(inst.accountId);
if (override) {
inst.finalAction = override.action;
inst.finalUseAvgConsumption = override.useAvgConsumption !== undefined
? override.useAvgConsumption
: inst.aiSuggestsConsumption;
} else {
inst.finalAction = inst.aiSuggestion;
inst.finalUseAvgConsumption = inst.aiSuggestsConsumption;
}
}
// Determine final sets
const finalTaggedSet = aiInstallations.filter((inst) => {
if (inst.finalAction === "TAG") return true;
if (inst.finalAction === "NO_CHANGE" && inst.currentTaggingStatus === "TAGGED") return true;
return false;
});
const newlyTaggedInstallations = aiInstallations.filter(
(inst) => inst.finalAction === "TAG" && inst.currentTaggingStatus === "UNTAGGED",
);
const newlyUntaggedInstallations = aiInstallations.filter(
(inst) => inst.finalAction === "UNTAG" && inst.currentTaggingStatus === "TAGGED",
);
// Step 5: Compute final audit metrics for target TC
const installationConsumption = finalTaggedSet.reduce(
(sum, inst) => sum + getEffectiveConsumption(inst), 0,
);
const installationCount = finalTaggedSet.length;
const effectiveTcConsumption = (transformer.tcConsumption || 0) * (transformer.meterConstant || 1);
let lossPercentage = null;
if (effectiveTcConsumption > 0) {
lossPercentage = ((effectiveTcConsumption - installationConsumption) / effectiveTcConsumption) * 100;
lossPercentage = Math.round(lossPercentage * 100) / 100;
}
const billingCount = {
unbilled: 0, mnr: 0, vacant: 0, zeroConsumption: 0,
doorlock: 0, abnormal: 0, subnormal: 0, billCancellation: 0,
};
for (const inst of finalTaggedSet) {
if (inst.unbilled) billingCount.unbilled++;
if (inst.mnr) billingCount.mnr++;
if (inst.vacant) billingCount.vacant++;
if (inst.zeroConsumption) billingCount.zeroConsumption++;
if (inst.doorLock) billingCount.doorlock++;
if (inst.emin != null && inst.emin > 0) billingCount.abnormal++;
if (inst.emax != null && inst.emax > 0) billingCount.subnormal++;
}
const totalInstallationSanctionedLoadInKVA = finalTaggedSet.reduce(
(sum, inst) => sum + (inst.sanctionedLoad?.kw || 0), 0,
);
const overloaded = transformer.tcCapacity
? totalInstallationSanctionedLoadInKVA > transformer.tcCapacity
: false;
const revenueParams = {
demand: finalTaggedSet.reduce((sum, inst) => sum + (inst.demand || 0), 0),
collection: finalTaggedSet.reduce((sum, inst) => sum + (inst.collection || 0), 0),
arr: null,
atc: null,
billingEfficiency: null,
collectionEfficiency: null,
};
if (revenueParams.demand > 0) {
revenueParams.billingEfficiency =
Math.round((installationConsumption / revenueParams.demand) * 100 * 100) / 100;
}
if (installationConsumption > 0) {
revenueParams.collectionEfficiency =
Math.round((revenueParams.collection / installationConsumption) * 100 * 100) / 100;
}
// Step 6a: Update target TC
const updatedTransformer = await transformerModel
.findOneAndUpdate(
{ id, month },
{
$set: {
installationConsumption,
installationCount,
lossPercentage,
auditStatus: "AUDITED",
auditedByAI: true,
auditedOn: new Date(),
billingCount,
revenueParams,
overloaded,
totalInstallationSanctionedLoadInKVA,
},
},
{
new: true,
projection: { _id: 0, __v: 0, createdAt: 0, updatedAt: 0, activatedOn: 0, deletedOn: 0, auditedOn: 0 },
},
)
.lean()
.catch(databaseServerErrorCatchBlockFunction);
// Step 6b: Update installation documents (this month only)
const installationTcId = id.replace(/^TC-/, "");
const newlyTaggedAccountIds = newlyTaggedInstallations.map((inst) => inst.accountId);
if (newlyTaggedAccountIds.length > 0) {
await installationModel
.updateMany(
{ accountId: { $in: newlyTaggedAccountIds }, month },
{ $set: { tcId: installationTcId, tcNumber: transformer.number } },
)
.catch(databaseServerErrorCatchBlockFunction);
}
const newlyUntaggedAccountIds = newlyUntaggedInstallations.map((inst) => inst.accountId);
if (newlyUntaggedAccountIds.length > 0) {
await installationModel
.updateMany(
{ accountId: { $in: newlyUntaggedAccountIds }, month },
{ $set: { tcId: null, tcNumber: null } },
)
.catch(databaseServerErrorCatchBlockFunction);
}
// Step 6c: Reaudit source TCs
const affectedSourceTcIds = [...new Set(
newlyTaggedInstallations
.filter((inst) => inst.sourceTcId)
.map((inst) => inst.sourceTcId),
)];
const affectedSourceTCResults = [];
for (const sourceTcId of affectedSourceTcIds) {
const sourceTransformer = await transformerModel
.findOne({ id: sourceTcId, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (!sourceTransformer) continue;
const sourceInstallationTcId = sourceTcId.replace(/^TC-/, "");
const sourceInstallations = await installationModel
.find({ tcId: sourceInstallationTcId, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
const sourceAuditFields = computeAuditFields(sourceTransformer, sourceInstallations);
delete sourceAuditFields.auditedByAI;
await transformerModel
.findOneAndUpdate({ id: sourceTcId, month }, { $set: sourceAuditFields })
.catch(databaseServerErrorCatchBlockFunction);
affectedSourceTCResults.push({
id: sourceTcId,
number: sourceTransformer.number,
lossPercentage: sourceAuditFields.lossPercentage,
});
}
// Step 6d: Update adjacent month loss %
const allAffectedTcIds = [id, ...affectedSourceTcIds];
for (const tcId of allAffectedTcIds) {
const tcDoc = await transformerModel
.findOne({ id: tcId, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (!tcDoc || tcDoc.lossPercentage == null) continue;
const nextMonth = getNextMonth(month);
const nextNextMonth = getNextMonth(nextMonth);
await transformerModel
.updateOne(
{ id: tcId, month: nextMonth },
{ $set: { previousMonthLossPercentage: tcDoc.lossPercentage } },
)
.catch(databaseServerErrorCatchBlockFunction);
await transformerModel
.updateOne(
{ id: tcId, month: nextNextMonth },
{ $set: { previousToPreviousMonthLossPercentage: tcDoc.lossPercentage } },
)
.catch(databaseServerErrorCatchBlockFunction);
}
// Step 6e: Cascade audit summaries
const affectedSections = new Set();
affectedSections.add(transformer.location.sectionCode);
for (const sourceTcId of affectedSourceTcIds) {
const sourceTc = await transformerModel
.findOne({ id: sourceTcId, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (sourceTc) affectedSections.add(sourceTc.location.sectionCode);
}
for (const sectionCode of affectedSections) {
const sampleTc = await transformerModel
.findOne({ "location.sectionCode": sectionCode, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (sampleTc) {
await recomputeAuditSummaries(sampleTc.location, month);
}
}
// Step 7: Mark as frozen
await aiAuditResultModel
.findOneAndUpdate(
{ tcId: id, month },
{ $set: { frozen: true, frozenOn: new Date() } },
)
.catch(databaseServerErrorCatchBlockFunction);
// Step 8: Return response
return {
statusCode: 200,
success: true,
message: "AI audit frozen successfully",
data: {
transformer: {
id: updatedTransformer.id,
number: updatedTransformer.number,
name: updatedTransformer.name,
month,
installationConsumption,
installationCount,
lossPercentage,
auditStatus: "AUDITED",
auditedByAI: true,
billingCount,
revenueParams,
overloaded,
},
affectedSourceTCs: affectedSourceTCResults,
},
};
} catch (error) {
console.error("Unexpected error while freezing AI audit", error);
throw error;
}
}
// ──── Handler ────
function resolveRouteType(userType) {
return userType === "AE" ? "ae" : "admin";
}
export const freezeAiAuditHandler = async (request, reply) => {
const routeType = resolveRouteType(request.user.userType);
const response = await freezeAiAudit(
routeType,
request.user,
request.params,
request.body,
);
reply.status(response.statusCode).send({
success: response.success,
message: response.message,
data: response.data,
});
};
/*
################################# Validation & Serialization #################################
*/
const freezeAiAuditParamsSchema = {
type: "object",
properties: {
id: {
type: "string",
transform: ["trim"],
},
},
required: ["id"],
};
const freezeAiAuditBodySchema = {
type: "object",
properties: {
month: {
type: "string",
pattern: "^\\d{4}-(0[1-9]|1[0-2])$",
errorMessage: {
pattern: "month must be in YYYY-MM format",
},
},
overrides: {
type: "array",
items: {
type: "object",
properties: {
accountId: { type: "string" },
action: { type: "string", enum: ["TAG", "UNTAG"] },
useAvgConsumption: { type: "boolean" },
},
required: ["accountId", "action"],
additionalProperties: false,
},
default: [],
},
},
required: ["month"],
additionalProperties: false,
errorMessage: {
additionalProperties: "Unsupported properties in request body",
},
};
const responseSerialization = {
"2xx": responseSerializer("2xx", {
type: "object",
properties: {
transformer: {
type: "object",
properties: {
id: { type: "string" },
number: { type: "string" },
name: { type: "string" },
month: { type: "string" },
installationConsumption: { type: ["number", "null"] },
installationCount: { type: "number" },
lossPercentage: { type: ["number", "null"] },
auditStatus: { type: "string" },
auditedByAI: { type: "boolean" },
billingCount: {
type: "object",
properties: {
unbilled: { type: "number" },
mnr: { type: "number" },
vacant: { type: "number" },
zeroConsumption: { type: "number" },
doorlock: { type: "number" },
abnormal: { type: "number" },
subnormal: { type: "number" },
billCancellation: { type: "number" },
},
},
revenueParams: {
type: "object",
properties: {
arr: { type: ["number", "null"] },
atc: { type: ["number", "null"] },
demand: { type: ["number", "null"] },
collection: { type: ["number", "null"] },
billingEfficiency: { type: ["number", "null"] },
collectionEfficiency: { type: ["number", "null"] },
},
},
overloaded: { type: "boolean" },
},
},
affectedSourceTCs: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
number: { type: "string" },
lossPercentage: { type: ["number", "null"] },
},
},
},
},
}),
"4xx": responseSerializer("4xx"),
"5xx": responseSerializer("5xx"),
};
export const freezeAiAuditSchema = {
params: freezeAiAuditParamsSchema,
body: freezeAiAuditBodySchema,
response: responseSerialization,
};
export const freezeAiAuditConfig = {
routeId: "freezeAiAudit",
authentication: {
/* Requires full auth (validation + verification) */
},
authorization: { action: "read", resource: "audit" },
};
Task 1: Register route in audit.route.v1.js
Files:
- Modify:
api/src/entities/audit/audit.route.v1.js
Step 1: Add import for freezeAiAudit controller
Add after the existing fetchAiAuditRemarks import:
import {
freezeAiAuditHandler,
freezeAiAuditSchema,
freezeAiAuditConfig,
} from "./controller/freezeAiAudit.js";
Step 2: Add route entry to the returned array
Add after the GET /audit/transformers/:id/ai-audit/remarks entry:
{
method: "POST",
url: "/audit/transformers/:id/freeze",
schema: freezeAiAuditSchema,
handler: freezeAiAuditHandler,
config: freezeAiAuditConfig,
},
Step 3: Verify — start the API server
yarn api-dev:pretty
Confirm no startup errors and the freeze route appears in the route list.
Task 2: Create Bruno files for Freeze endpoint
Files:
- Create:
bruno/AI Audit/TC/AI Audit Freeze/AE - Freeze AI Audit.bru - Create:
bruno/AI Audit/TC/AI Audit Freeze/CIO - Freeze AI Audit.bru
Step 1: Create AE request file
meta {
name: AE - Freeze AI Audit
type: http
seq: 1
}
post {
url: {{BASE_URL}}/dtcea-mumbai-demo/v1/audit/transformers/:id/freeze
body: json
auth: bearer
}
params:path {
id: TC-01
}
auth:bearer {
token: {{AE_SESSION}}
}
body:json {
{
"month": "2026-03",
"overrides": []
}
}
Step 2: Create CIO request file
meta {
name: CIO - Freeze AI Audit
type: http
seq: 2
}
post {
url: {{BASE_URL}}/dtcea-mumbai-demo/v1/audit/transformers/:id/freeze
body: json
auth: bearer
}
params:path {
id: TC-01
}
auth:bearer {
token: {{CIO_SESSION}}
}
body:json {
{
"month": "2026-03",
"overrides": []
}
}
Task 3: Test — Freeze with no overrides (accept all AI suggestions)
Pre-requisite: AI Audit Run must have been executed on TC-01 for the test month AND the result must NOT be frozen yet. If already frozen from a previous test, re-run the AI audit first.
Step 1: Verify ai-audit-result is not frozen
mongosh "mongodb+srv://demo:GhKYzU37veEYEmrc@energyaudit.0l6duna.mongodb.net/dtcea-mumbai-demo" --quiet --eval 'db.getCollection("ai-audit-result").findOne({ tcId: "TC-01" }, { frozen: 1, month: 1 })'
Expected: frozen: false
Step 2: Hit the freeze endpoint via Bruno
Run: AE - Freeze AI Audit (TC-01, month 2026-03, empty overrides)
Expected response (200):
data.transformer— updated TC with newinstallationConsumption,installationCount,lossPercentage,auditStatus: "AUDITED",auditedByAI: true,billingCount,revenueParamsdata.affectedSourceTCs— array of source TCs that were reaudited (each withid,number,lossPercentage)
Step 3: Verify in DB — ai-audit-result is frozen
mongosh "mongodb+srv://demo:GhKYzU37veEYEmrc@energyaudit.0l6duna.mongodb.net/dtcea-mumbai-demo" --quiet --eval 'db.getCollection("ai-audit-result").findOne({ tcId: "TC-01" }, { frozen: 1, frozenOn: 1 })'
Expected: frozen: true, frozenOn: <date>
Step 4: Verify — re-freeze should return 400
Hit the same Bruno request again.
Expected: 400 — "AI audit already frozen"
Step 5: Verify — installation tagging changed in real collection
mongosh "mongodb+srv://demo:GhKYzU37veEYEmrc@energyaudit.0l6duna.mongodb.net/dtcea-mumbai-demo" --quiet --eval 'db.getCollection("installation").countDocuments({ tcId: "01", month: "2026-03" })'
Should reflect the new count of installations tagged to TC-01 after the freeze.
Task 4: Update scope tracker
Files:
- Modify:
docs/scope.md
Step 1: Update API Quick Reference table
Change status for the freeze endpoint from Not started to Done:
| POST | `/audit/transformers/:id/freeze` | [LLD](api/LLD-ai-audit-freeze.md) | Done |
Step 2: Move Section 12 from Remaining to Completed
Move "AI Audit — Freeze" from ## Remaining to ## Completed and update with implementation details.
Step 3: Update UI Screen → API Mapping
| AI Audit page — Freeze the result | `POST /audit/transformers/:id/freeze` | Done |
Step 4: Update progress summary
- Completed endpoints: 19 → 20
- New endpoints remaining: 1 → 0
Task 5: Commit
Step 1: Ask user for permission, then stage and commit
git add api/src/entities/audit/controller/freezeAiAudit.js \
api/src/entities/audit/audit.route.v1.js \
"bruno/AI Audit/TC/AI Audit Freeze/" \
docs/scope.md
git commit -m "feat: add AI audit freeze endpoint — commits staging data to real collections"