Skip to main content

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)
  • original block (pre-AI audit values snapshot)
  • computed block (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)
  • original and computed blocks with all audit value fields
  • installationTaggingSummary with total, changesSuggestedByAI, noChangesRequired
  • installationRemarksSummary with total, 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"