AI Audit Run — 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/ai-audit — runs GPS-based installation classification + remark analysis, stores results in staging collections, returns original vs. computed audit comparison.
Architecture: Two new Mongoose models (aiAuditResult, aiAuditInstallation) in common/src/schemas/. One new controller (aiAuditTC.js) with a handler following the existing auditTC.js pattern. Two small helper modules (gpsConstants.js, deriveRemarkType.js). Route added to audit.route.v1.js. Schema changes: add 2dsphere index on transformer + 10 new fields on transformer schema per schemas LLD.
Tech Stack: Node.js (ESM), Mongoose, Fastify v5, MongoDB $geoNear aggregation, dayjs
Prerequisite: Schema Changes
The schemas LLD (LLD-ai-audit-schemas.md) specifies changes to transformer.model.js (10 new TC/meter detail fields + 2dsphere index) and installation.model.js (2dsphere index). The avgConsumption6Months field on installation is already added. These schema changes are needed before the AI audit controller can work.
Task 0: Add 2dsphere index + new fields to transformer schema
Files:
- Modify:
common/src/schemas/transformer.model.js
Step 1: Add TC Detail + Meter Detail fields and 2dsphere index
Add after the reading block (after line 79):
// ──── TC Detail Properties ────
tcMake: { type: String },
serialNumber: { type: String },
timsCode: { type: String },
dtlms: { type: String },
dtr: { type: String },
feeder: { type: String },
executionType: { type: String },
// ──── Meter Detail Properties ────
meterMake: { type: String },
meterSerial: { type: String },
ctRatio: { type: String },
Add the 2dsphere index after the existing indexes (after line 184):
// GPS-based queries for AI audit ($geoNear)
transformerSchema.index({ gpsCoordinates: "2dsphere" });
Step 2: Add 2dsphere index to installation schema
File: common/src/schemas/installation.model.js
Add after the existing indexes (after line 164):
// GPS-based queries for AI audit ($geoNear)
installationSchema.index({ gpsCoordinates: "2dsphere" });
Step 3: Commit
git add common/src/schemas/transformer.model.js common/src/schemas/installation.model.js
git commit -m "feat: add TC/meter detail fields and 2dsphere indexes for AI audit"
Task 1: Create aiAuditResult model
Files:
- Create:
common/src/schemas/aiAuditResult.model.js
Step 1: Create the model file
Copy the full schema from LLD-ai-audit-schemas.md Section 2.1. The schema defines:
- Identity:
tcId,tcNumber,tcName,month - Location (flat, same as transformer)
- TC Properties snapshot (capacity, meterConstant, GPS, readings)
- TC Detail Properties (tcMake, serialNumber, timsCode, dtlms, dtr, feeder, executionType)
- Meter Detail Properties (meterMake, meterSerial, ctRatio)
originalblock (pre-AI audit values snapshot)computedblock (post-AI projected values)tcConsumption,previousMonthLossPercentage,previousToPreviousMonthLossPercentage(read-only from transformer)frozen(Boolean, default false),frozenOn(Date, default null)- Unique index on
{ tcId: 1, month: 1 } - Collection name:
ai-audit-result
"use strict";
import mongoose from "mongoose";
const billingCountShape = {
unbilled: { type: Number, default: 0 },
mnr: { type: Number, default: 0 },
vacant: { type: Number, default: 0 },
zeroConsumption: { type: Number, default: 0 },
doorlock: { type: Number, default: 0 },
abnormal: { type: Number, default: 0 },
subnormal: { type: Number, default: 0 },
billCancellation: { type: Number, default: 0 },
};
const revenueParamsShape = {
arr: { type: Number },
atc: { type: Number },
demand: { type: Number },
collection: { type: Number },
billingEfficiency: { type: Number },
collectionEfficiency: { type: Number },
};
const aiAuditResultSchema = new mongoose.Schema(
{
// ──── Identity ────
tcId: { type: String, required: true },
tcNumber: { type: String, required: true },
tcName: { type: String, required: true },
month: {
type: String,
required: true,
validate: {
validator: (v) => /^\d{4}-(0[1-9]|1[0-2])$/.test(v),
message: "month must be in YYYY-MM format",
},
},
// ──── Location (flat, same as transformer) ────
location: {
sectionCode: { type: String, required: true },
sectionName: { type: String },
subDivisionCode: { type: String, required: true },
subDivisionName: { type: String },
divisionCode: { type: String, required: true },
divisionName: { type: String },
circleCode: { type: String, required: true },
circleName: { type: String },
zoneCode: { type: String, required: true },
zoneName: { type: String },
},
// ──── TC Properties (copied from transformer at audit time) ────
tcCapacity: { type: Number },
meterConstant: { type: Number },
meterConstantChanged: { type: Boolean, default: false },
readingDay: { type: Number, min: 1, max: 31 },
readingMR: { type: String },
gpsCoordinates: {
type: { type: String, enum: ["Point"], default: "Point" },
coordinates: { type: [Number] },
},
reading: {
initialValue: { type: Number },
finalValue: { type: Number },
},
// ──── TC Detail Properties (copied from transformer) ────
tcMake: { type: String },
serialNumber: { type: String },
timsCode: { type: String },
dtlms: { type: String },
dtr: { type: String },
feeder: { type: String },
executionType: { type: String },
// ──── Meter Detail Properties (copied from transformer) ────
meterMake: { type: String },
meterSerial: { type: String },
ctRatio: { type: String },
// ──── Original Audit Values (before AI audit) ────
original: {
installationConsumption: { type: Number },
installationCount: { type: Number },
lossPercentage: { type: Number },
auditStatus: { type: String, enum: ["AUDITED", "AUDIT_FAILED"] },
billingCount: billingCountShape,
revenueParams: revenueParamsShape,
overloaded: { type: Boolean },
totalInstallationSanctionedLoadInKVA: { type: Number },
},
// ──── Computed AI Audit Values (after GPS + remarks analysis) ────
computed: {
installationConsumption: { type: Number },
installationCount: { type: Number },
lossPercentage: { type: Number },
auditStatus: { type: String, enum: ["AUDITED", "AUDIT_FAILED"] },
billingCount: billingCountShape,
revenueParams: revenueParamsShape,
overloaded: { type: Boolean },
totalInstallationSanctionedLoadInKVA: { type: Number },
},
// ──── TC Consumption (read-only, from transformer) ────
tcConsumption: { type: Number },
previousMonthLossPercentage: { type: Number },
previousToPreviousMonthLossPercentage: { type: Number },
// ──── Freeze Meta ────
frozen: { type: Boolean, default: false },
frozenOn: { type: Date, default: null },
},
{
timestamps: true,
collection: "ai-audit-result",
},
);
aiAuditResultSchema.index({ tcId: 1, month: 1 }, { unique: true });
export const aiAuditResultModel = mongoose.model(
"AiAuditResult",
aiAuditResultSchema,
"ai-audit-result",
);
Step 2: Commit
git add common/src/schemas/aiAuditResult.model.js
git commit -m "feat: add aiAuditResult model (staging collection for AI audit)"
Task 2: Create aiAuditInstallation model
Files:
- Create:
common/src/schemas/aiAuditInstallation.model.js
Step 1: Create the model file
Copy the full schema from LLD-ai-audit-schemas.md Section 3.1. Key fields:
- Link:
aiAuditResultId,tcId,month - Installation identity:
accountId,installationId,rrNumber,consumerName, etc. - Table 1 (Tagging):
distanceFromTC,withinRange,currentTaggingStatus,aiSuggestion,sourceTcId,sourceTcNumber - Table 2 (Remarks):
hasRemark,remarkType,avgConsumption6Months,aiSuggestsConsumption - Billing flags:
unbilled,mnr,vacant,doorLock,zeroConsumption,emin,emax - Indexes: unique
{ aiAuditResultId, accountId }, plus{ aiAuditResultId, hasRemark }and{ aiAuditResultId, aiSuggestion } - Collection name:
ai-audit-installation
"use strict";
import mongoose from "mongoose";
const aiAuditInstallationSchema = new mongoose.Schema(
{
// ──── Link to AI Audit Result ────
aiAuditResultId: { type: String, required: true },
tcId: { type: String, required: true },
month: {
type: String,
required: true,
validate: {
validator: (v) => /^\d{4}-(0[1-9]|1[0-2])$/.test(v),
message: "month must be in YYYY-MM format",
},
},
// ──── Installation Identity (copied from installation doc) ────
accountId: { type: String, required: true },
installationId: { type: String },
rrNumber: { type: String },
consumerName: { type: String },
mrCode: { type: String },
readingDay: { type: Number, min: 1, max: 31 },
tariff: {
short: { type: String },
long: { type: String },
},
sanctionedLoad: {
kw: { type: Number },
hp: { type: Number },
},
gpsCoordinates: {
type: { type: String, enum: ["Point"], default: "Point" },
coordinates: { type: [Number] },
},
// ──── Installation Data for this Month ────
consumption: { type: Number },
demand: { type: Number },
collection: { type: Number },
// ──── Table 1: Tagging Fields (GPS distance analysis) ────
distanceFromTC: { type: Number },
withinRange: { type: Boolean },
currentTaggingStatus: {
type: String,
enum: ["TAGGED", "UNTAGGED"],
},
aiSuggestion: {
type: String,
enum: ["TAG", "UNTAG", "NO_CHANGE"],
},
sourceTcId: { type: String, default: null },
sourceTcNumber: { type: String, default: null },
// ──── Table 2: Remarks Fields (historical consumption analysis) ────
hasRemark: { type: Boolean, default: false },
remarkType: {
type: String,
enum: [
"UNBILLED", "MNR", "VACANT", "ZERO_CONSUMPTION",
"DOORLOCK", "ABNORMAL", "SUBNORMAL", null,
],
default: null,
},
avgConsumption6Months: { type: Number, default: null },
aiSuggestsConsumption: { type: Boolean, default: false },
// ──── Billing Flags (copied from installation) ────
unbilled: { type: Boolean },
mnr: { type: Boolean },
vacant: { type: Boolean },
doorLock: { type: Boolean },
zeroConsumption: { type: Boolean },
emin: { type: Number },
emax: { type: Number },
},
{
timestamps: true,
collection: "ai-audit-installation",
},
);
// ──── Indexes ────
aiAuditInstallationSchema.index(
{ aiAuditResultId: 1, accountId: 1 },
{ unique: true },
);
aiAuditInstallationSchema.index({ aiAuditResultId: 1, hasRemark: 1 });
aiAuditInstallationSchema.index({ aiAuditResultId: 1, aiSuggestion: 1 });
export const aiAuditInstallationModel = mongoose.model(
"AiAuditInstallation",
aiAuditInstallationSchema,
"ai-audit-installation",
);
Step 2: Commit
git add common/src/schemas/aiAuditInstallation.model.js
git commit -m "feat: add aiAuditInstallation model (staging collection for AI audit)"
Task 3: Create helper modules (gpsConstants.js, deriveRemarkType.js)
Files:
- Create:
api/src/entities/audit/helpers/gpsConstants.js - Create:
api/src/entities/audit/helpers/deriveRemarkType.js
Step 1: Create gpsConstants.js
"use strict";
/** GPS range threshold in meters — installations within this distance are "within range" */
export const GPS_RANGE_THRESHOLD_METERS = 5000;
Step 2: Create deriveRemarkType.js
"use strict";
/**
* Derives the remark type from an installation's billing flags.
* Priority order matches LLD-ai-audit-schemas.md Section 7.1.
*
* @param {Object} inst — installation document (lean)
* @returns {string|null} — one of UNBILLED, MNR, VACANT, ZERO_CONSUMPTION, DOORLOCK, ABNORMAL, SUBNORMAL, or null
*/
export function deriveRemarkType(inst) {
if (inst.unbilled) return "UNBILLED";
if (inst.mnr) return "MNR";
if (inst.vacant) return "VACANT";
if (inst.zeroConsumption) return "ZERO_CONSUMPTION";
if (inst.doorLock) return "DOORLOCK";
if (inst.emin != null && inst.emin > 0) return "ABNORMAL";
if (inst.emax != null && inst.emax > 0) return "SUBNORMAL";
return null;
}
Step 3: Commit
git add api/src/entities/audit/helpers/gpsConstants.js api/src/entities/audit/helpers/deriveRemarkType.js
git commit -m "feat: add GPS constants and deriveRemarkType helper for AI audit"
Task 4: Create aiAuditTC controller
This is the main controller. It follows the same pattern as auditTC.js — exports aiAuditTCHandler, aiAuditTCSchema, aiAuditTCConfig.
Files:
- Create:
api/src/entities/audit/controller/aiAuditTC.js
Step 1: Create the controller file
The controller implements all 10 steps from LLD Section 6:
"use strict";
import dayjs from "dayjs";
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 { aiAuditResultModel } from "../../../../../common/src/schemas/aiAuditResult.model.js";
import { aiAuditInstallationModel } from "../../../../../common/src/schemas/aiAuditInstallation.model.js";
import {
DatabaseError,
ForbiddenError,
ResourceUnavailableError,
ValidationError,
} from "../../../utils/customErrorInterfaces.js";
import { responseSerializer } from "../../../utils/responseSerializationSchema.js";
import { GPS_RANGE_THRESHOLD_METERS } from "../helpers/gpsConstants.js";
import { deriveRemarkType } from "../helpers/deriveRemarkType.js";
const databaseServerErrorCatchBlockFunction = (error) => {
console.error(error);
throw new DatabaseError({
userMessage: "Unexpected error while running AI audit",
sourceMessage: error.message,
name: error.constructor.name,
});
};
// ──── Core Logic ────
async function aiAuditTC(routeType, requestUser, params, queryParams) {
try {
const month = queryParams.month || dayjs().format("YYYY-MM");
const { id } = params;
// Step 1: Fetch TC
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`,
});
}
// Precondition 2: AE section validation
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}`,
});
}
}
// Precondition 3: TC must not be AUDIT_FAILED
if (
transformer.remark ||
!transformer.meterConstant ||
transformer.tcConsumption == null
) {
throw new ValidationError({
userMessage: "TC is not eligible for AI audit",
sourceMessage: `TC id=${id} has remark=${transformer.remark}, meterConstant=${transformer.meterConstant}, tcConsumption=${transformer.tcConsumption}`,
name: "ValidationError",
});
}
// Precondition 4: TC must not already be AI-audited (frozen)
if (transformer.auditedByAI === true) {
throw new ValidationError({
userMessage: "AI audit already completed and frozen for this TC",
sourceMessage: `TC id=${id} already has auditedByAI=true`,
name: "ValidationError",
});
}
// Precondition 5: Clean up existing unfrozen result
const existingResult = await aiAuditResultModel
.findOne({ tcId: id, month })
.lean()
.catch(databaseServerErrorCatchBlockFunction);
if (existingResult) {
if (existingResult.frozen) {
throw new ValidationError({
userMessage: "AI audit already completed and frozen for this TC",
sourceMessage: `Frozen ai-audit-result exists for tcId=${id}, month=${month}`,
name: "ValidationError",
});
}
// Delete unfrozen result + linked installations
await aiAuditInstallationModel
.deleteMany({ aiAuditResultId: existingResult._id.toString() })
.catch(databaseServerErrorCatchBlockFunction);
await aiAuditResultModel
.deleteOne({ _id: existingResult._id })
.catch(databaseServerErrorCatchBlockFunction);
}
// Step 3: Fetch all installations in section via $geoNear
const installations = await installationModel
.aggregate([
{
$geoNear: {
near: transformer.gpsCoordinates,
distanceField: "distanceFromTC",
spherical: true,
query: {
"location.sectionCode": transformer.location.sectionCode,
month,
},
},
},
])
.catch(databaseServerErrorCatchBlockFunction);
// Step 4: Classify into sets A, B, C, Remaining
const installationTcId = id.replace(/^TC-/, "");
const setA = []; // TAGGED + within range → NO_CHANGE
const setB = []; // TAGGED + out of range → UNTAG
const setC = []; // UNTAGGED + within range → TAG
const remaining = []; // UNTAGGED + out of range → NO_CHANGE
for (const inst of installations) {
const isTaggedToThisTC = inst.tcId === installationTcId;
const withinRange = inst.distanceFromTC <= GPS_RANGE_THRESHOLD_METERS;
if (isTaggedToThisTC && withinRange) setA.push(inst);
else if (isTaggedToThisTC && !withinRange) setB.push(inst);
else if (!isTaggedToThisTC && withinRange) setC.push(inst);
else remaining.push(inst);
}
// Step 6: Snapshot original audit values from transformer
const original = {
installationConsumption: transformer.installationConsumption,
installationCount: transformer.installationCount,
lossPercentage: transformer.lossPercentage,
auditStatus: transformer.auditStatus,
billingCount: transformer.billingCount,
revenueParams: transformer.revenueParams,
overloaded: transformer.overloaded,
totalInstallationSanctionedLoadInKVA:
transformer.totalInstallationSanctionedLoadInKVA,
};
// Step 7: Compute projected ("new") audit values
// New tagged set = A (keep) + C (newly tagged). B is removed.
const newTaggedSet = [...setA, ...setC];
function getEffectiveConsumption(inst) {
const remarkType = deriveRemarkType(inst);
const hasRemark = remarkType !== null;
const avg = inst.avgConsumption6Months;
const aiSuggestsConsumption = hasRemark && avg != null && avg > 0;
if (aiSuggestsConsumption) return avg;
return inst.consumption || 0;
}
const newInstallationConsumption = newTaggedSet.reduce(
(sum, inst) => sum + getEffectiveConsumption(inst),
0,
);
const newInstallationCount = newTaggedSet.length;
const effectiveTcConsumption =
(transformer.tcConsumption || 0) * (transformer.meterConstant || 1);
let newLossPercentage = null;
if (effectiveTcConsumption > 0) {
newLossPercentage =
((effectiveTcConsumption - newInstallationConsumption) /
effectiveTcConsumption) *
100;
newLossPercentage = Math.round(newLossPercentage * 100) / 100;
}
const newBillingCount = {
unbilled: 0,
mnr: 0,
vacant: 0,
zeroConsumption: 0,
doorlock: 0,
abnormal: 0,
subnormal: 0,
billCancellation: 0,
};
for (const inst of newTaggedSet) {
if (inst.unbilled) newBillingCount.unbilled++;
if (inst.mnr) newBillingCount.mnr++;
if (inst.vacant) newBillingCount.vacant++;
if (inst.zeroConsumption) newBillingCount.zeroConsumption++;
if (inst.doorLock) newBillingCount.doorlock++;
if (inst.emin != null && inst.emin > 0) newBillingCount.abnormal++;
if (inst.emax != null && inst.emax > 0) newBillingCount.subnormal++;
}
const newTotalSanctionedLoad = newTaggedSet.reduce(
(sum, inst) => sum + (inst.sanctionedLoad?.kw || 0),
0,
);
const newOverloaded = transformer.tcCapacity
? newTotalSanctionedLoad > transformer.tcCapacity
: false;
const newRevenueParams = {
demand: newTaggedSet.reduce(
(sum, inst) => sum + (inst.demand || 0),
0,
),
collection: newTaggedSet.reduce(
(sum, inst) => sum + (inst.collection || 0),
0,
),
arr: null,
atc: null,
billingEfficiency: null,
collectionEfficiency: null,
};
if (newRevenueParams.demand > 0) {
newRevenueParams.billingEfficiency =
Math.round(
(newInstallationConsumption / newRevenueParams.demand) * 100 * 100,
) / 100;
}
if (newInstallationConsumption > 0) {
newRevenueParams.collectionEfficiency =
Math.round(
(newRevenueParams.collection / newInstallationConsumption) *
100 *
100,
) / 100;
}
const newAuditStatus = "AUDITED";
const computed = {
installationConsumption: newInstallationConsumption,
installationCount: newInstallationCount,
lossPercentage: newLossPercentage,
auditStatus: newAuditStatus,
billingCount: newBillingCount,
revenueParams: newRevenueParams,
overloaded: newOverloaded,
totalInstallationSanctionedLoadInKVA: newTotalSanctionedLoad,
};
// Step 8: Create ai-audit-result document
const aiAuditResult = await aiAuditResultModel
.create({
tcId: id,
tcNumber: transformer.number,
tcName: transformer.name,
month,
location: transformer.location,
tcCapacity: transformer.tcCapacity,
meterConstant: transformer.meterConstant,
meterConstantChanged: transformer.meterConstantChanged || false,
readingDay: transformer.readingDay,
readingMR: transformer.readingMR,
gpsCoordinates: transformer.gpsCoordinates,
reading: transformer.reading,
tcMake: transformer.tcMake,
serialNumber: transformer.serialNumber,
timsCode: transformer.timsCode,
dtlms: transformer.dtlms,
dtr: transformer.dtr,
feeder: transformer.feeder,
executionType: transformer.executionType,
meterMake: transformer.meterMake,
meterSerial: transformer.meterSerial,
ctRatio: transformer.ctRatio,
original,
computed,
tcConsumption: transformer.tcConsumption,
previousMonthLossPercentage: transformer.previousMonthLossPercentage,
previousToPreviousMonthLossPercentage:
transformer.previousToPreviousMonthLossPercentage,
frozen: false,
})
.catch(databaseServerErrorCatchBlockFunction);
// Step 9: Create ai-audit-installation documents (bulk)
const aiAuditResultId = aiAuditResult._id.toString();
function buildInstallationDoc(
inst,
currentTaggingStatus,
aiSuggestion,
sourceTcId,
sourceTcNumber,
) {
const remarkType = deriveRemarkType(inst);
const hasRemark = remarkType !== null;
const avg = inst.avgConsumption6Months || null;
const aiSuggestsConsumption = hasRemark && avg != null && avg > 0;
return {
aiAuditResultId,
tcId: id,
month,
accountId: inst.accountId,
installationId: inst.id,
rrNumber: inst.rrNumber,
consumerName: inst.consumerName,
mrCode: inst.mrCode,
readingDay: inst.readingDay,
tariff: inst.tariff,
sanctionedLoad: inst.sanctionedLoad,
gpsCoordinates: inst.gpsCoordinates,
consumption: inst.consumption,
demand: inst.demand,
collection: inst.collection,
distanceFromTC: inst.distanceFromTC,
withinRange: inst.distanceFromTC <= GPS_RANGE_THRESHOLD_METERS,
currentTaggingStatus,
aiSuggestion,
sourceTcId: sourceTcId || null,
sourceTcNumber: sourceTcNumber || null,
hasRemark,
remarkType,
avgConsumption6Months: avg,
aiSuggestsConsumption,
unbilled: inst.unbilled,
mnr: inst.mnr,
vacant: inst.vacant,
doorLock: inst.doorLock,
zeroConsumption: inst.zeroConsumption,
emin: inst.emin,
emax: inst.emax,
};
}
const installationDocs = [];
for (const inst of setA) {
installationDocs.push(
buildInstallationDoc(inst, "TAGGED", "NO_CHANGE", null, null),
);
}
for (const inst of setB) {
installationDocs.push(
buildInstallationDoc(inst, "TAGGED", "UNTAG", null, null),
);
}
for (const inst of setC) {
installationDocs.push(
buildInstallationDoc(inst, "UNTAGGED", "TAG", inst.tcId, inst.tcNumber),
);
}
for (const inst of remaining) {
installationDocs.push(
buildInstallationDoc(
inst,
"UNTAGGED",
"NO_CHANGE",
inst.tcId,
inst.tcNumber,
),
);
}
if (installationDocs.length > 0) {
await aiAuditInstallationModel
.insertMany(installationDocs)
.catch(databaseServerErrorCatchBlockFunction);
}
// Step 10: Build response
const changesSuggestedByAI = setB.length + setC.length;
const noChangesRequired = setA.length + remaining.length;
const allRemarkInstallations = installationDocs.filter((d) => d.hasRemark);
const consumptionSuggested = installationDocs.filter(
(d) => d.aiSuggestsConsumption,
);
return {
statusCode: 200,
success: true,
message: "AI audit completed",
data: {
aiAuditResultId,
tcId: id,
tcNumber: transformer.number,
tcName: transformer.name,
month,
meterConstant: transformer.meterConstant,
meterConstantChanged: transformer.meterConstantChanged || false,
tcConsumption: transformer.tcConsumption,
previousMonthLossPercentage: transformer.previousMonthLossPercentage,
previousToPreviousMonthLossPercentage:
transformer.previousToPreviousMonthLossPercentage,
original,
computed,
installationTaggingSummary: {
total: installations.length,
changesSuggestedByAI,
noChangesRequired,
},
installationRemarksSummary: {
total: allRemarkInstallations.length,
consumptionSuggested: consumptionSuggested.length,
},
},
};
} catch (error) {
console.error("Unexpected error while running AI audit", error);
throw error;
}
}
// ──── Handler ────
function resolveRouteType(userType) {
return userType === "AE" ? "ae" : "admin";
}
export const aiAuditTCHandler = async (request, reply) => {
const routeType = resolveRouteType(request.user.userType);
const response = await aiAuditTC(
routeType,
request.user,
request.params,
request.query,
);
reply.status(response.statusCode).send({
success: response.success,
message: response.message,
data: response.data,
});
};
/*
################################# Validation & Serialization #################################
*/
const aiAuditTCParamsSchema = {
type: "object",
properties: {
id: {
type: "string",
transform: ["trim"],
},
},
required: ["id"],
};
const aiAuditTCQuerySchema = {
type: "object",
properties: {
month: {
type: "string",
pattern: "^\\d{4}-(0[1-9]|1[0-2])$",
errorMessage: {
pattern: "month must be in YYYY-MM format",
},
},
},
additionalProperties: false,
errorMessage: {
additionalProperties: "Unsupported properties in query parameters",
},
};
// ──── Response serialization (from LLD Section 8.3) ────
const auditValuesShape = {
type: "object",
properties: {
installationConsumption: { type: ["number", "null"] },
installationCount: { type: ["number", "null"] },
lossPercentage: { type: ["number", "null"] },
auditStatus: { type: ["string", "null"] },
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" },
totalInstallationSanctionedLoadInKVA: { type: ["number", "null"] },
},
};
const responseSerialization = {
"2xx": responseSerializer("2xx", {
type: "object",
properties: {
aiAuditResultId: { type: "string" },
tcId: { type: "string" },
tcNumber: { type: "string" },
tcName: { type: "string" },
month: { type: "string" },
meterConstant: { type: ["number", "null"] },
meterConstantChanged: { type: "boolean" },
tcConsumption: { type: ["number", "null"] },
previousMonthLossPercentage: { type: ["number", "null"] },
previousToPreviousMonthLossPercentage: { type: ["number", "null"] },
original: auditValuesShape,
computed: auditValuesShape,
installationTaggingSummary: {
type: "object",
properties: {
total: { type: "number" },
changesSuggestedByAI: { type: "number" },
noChangesRequired: { type: "number" },
},
},
installationRemarksSummary: {
type: "object",
properties: {
total: { type: "number" },
consumptionSuggested: { type: "number" },
},
},
},
}),
"4xx": responseSerializer("4xx"),
"5xx": responseSerializer("5xx"),
};
export const aiAuditTCSchema = {
params: aiAuditTCParamsSchema,
querystring: aiAuditTCQuerySchema,
response: responseSerialization,
};
export const aiAuditTCConfig = {
routeId: "aiAuditTC",
authentication: {
/* Requires full auth (validation + verification) */
},
authorization: { action: "read", resource: "audit" },
};
Step 2: Commit
git add api/src/entities/audit/controller/aiAuditTC.js
git commit -m "feat: add aiAuditTC controller — GPS classification + remark analysis"
Task 5: Register route in audit.route.v1.js
Files:
- Modify:
api/src/entities/audit/audit.route.v1.js
Step 1: Add import
Add after the existing auditTC import (after line 31):
import {
aiAuditTCHandler,
aiAuditTCSchema,
aiAuditTCConfig,
} from "./controller/aiAuditTC.js";
Step 2: Add route entry
Add to the returned array (after the auditTC route, after line 81):
{
method: "POST",
url: "/audit/transformers/:id/ai-audit",
schema: aiAuditTCSchema,
handler: aiAuditTCHandler,
config: aiAuditTCConfig,
},
Step 3: Commit
git add api/src/entities/audit/audit.route.v1.js
git commit -m "feat: register POST /audit/transformers/:id/ai-audit route"
Task 6: Test the endpoint locally
Step 1: Start the API server
yarn api-dev
Verify no startup errors — Mongoose model registration for AiAuditResult and AiAuditInstallation should succeed, and 2dsphere indexes should be created.
Step 2: Test with a known TC from seed data
Use a TC from the seed data that has GPS coordinates, meter constant, and tcConsumption (i.e., an AUDITED TC with no remark). Query the DB first to find a suitable TC ID, then:
curl -X POST \
'http://localhost:3000/dtcea-mumbai-demo/v1/audit/transformers/<TC_ID>/ai-audit?month=2025-10' \
-H 'Authorization: Bearer <session_token>' \
-H 'Content-Type: application/json'
Step 3: Verify response structure
Check that the response includes:
aiAuditResultId(non-null string)originalandcomputedblocks with all audit value fieldsinstallationTaggingSummarywithtotal,changesSuggestedByAI,noChangesRequiredinstallationRemarksSummarywithtotal,consumptionSuggested
Step 4: Verify DB state
Check ai-audit-result collection has 1 doc with frozen: false.
Check ai-audit-installation collection has docs linked by aiAuditResultId.
Step 5: Test re-trigger (delete-and-recreate)
Call the same endpoint again — should delete the old result and create a new one. Verify only 1 result doc exists.
Step 6: Test error cases
- TC with remark → 400 "TC is not eligible"
- Non-existent TC ID → 404 "Transformer not found"
- TC with
auditedByAI: true→ 400 "Already frozen"
Task 7: Update scope tracker
Files:
- Modify:
docs/scope.md
Step 1: Update status
Change POST /audit/transformers/:id/ai-audit from "Not started" to "Done" in both the API Quick Reference table and the UI Screen mapping table. Update the progress summary counts.
Step 2: Commit
git add docs/scope.md
git commit -m "docs: update scope tracker — AI audit run endpoint marked done"