Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BulkUpload Fixes #2625

Merged
merged 44 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
5876b0e
fix for getClassificationOrThrow
TaylorFries Aug 14, 2024
1e3b1f1
remove unnecessary test and data
TaylorFries Aug 14, 2024
6c87b0c
add name to bulk upload required headers
TaylorFries Aug 14, 2024
bd0b38b
update buildingId to 1 for bulk upload
TaylorFries Aug 14, 2024
3f1688c
more updates to building bulk upload process
TaylorFries Aug 15, 2024
3767524
updates to parcel bulk upload
TaylorFries Aug 15, 2024
0b6771e
fixes for new building bulk upload
TaylorFries Aug 16, 2024
b201207
new parcel bulk upload fix
TaylorFries Aug 16, 2024
0ef2f27
ignore no any
TaylorFries Aug 16, 2024
3a68126
missed a comma
TaylorFries Aug 16, 2024
52f3903
more checks on what should be updated
TaylorFries Aug 16, 2024
3e41f15
linting fix
TaylorFries Aug 19, 2024
d1e367e
update how pin is set
TaylorFries Aug 19, 2024
d7d2728
move some required headers to optional headers
TaylorFries Aug 19, 2024
9d26cd1
update how the bools are set
TaylorFries Aug 19, 2024
c12d6df
Merge branch 'main' into bulkupload-classification-quick-fix
TaylorFries Aug 20, 2024
53a6a3c
add more tests to please the jest
TaylorFries Aug 20, 2024
536f7d2
fixing css property warning
dbarkowsky Aug 20, 2024
f25d4cd
some updates
TaylorFries Aug 20, 2024
1dc8550
Merge branch 'bulkupload-classification-quick-fix' of https://github.…
TaylorFries Aug 20, 2024
456d31b
fixes for new entries
TaylorFries Aug 21, 2024
405e5ed
Merge branch 'main' into bulkupload-classification-quick-fix
TaylorFries Aug 21, 2024
40e9968
some more small fixes
TaylorFries Aug 21, 2024
8c9772e
Merge branch 'bulkupload-classification-quick-fix' of https://github.…
TaylorFries Aug 21, 2024
5654496
lint fix
TaylorFries Aug 21, 2024
73b42e5
removing old commented code
dbarkowsky Aug 21, 2024
872236f
Added check for multiple buildings with pid name returned
TaylorFries Aug 21, 2024
c6e31c8
Merge branch 'bulkupload-classification-quick-fix' of https://github.…
TaylorFries Aug 21, 2024
86b33be
remove name from parcel upload/insert
TaylorFries Aug 22, 2024
9a59e07
add required header check
TaylorFries Aug 22, 2024
bd3fc99
update process for when an error is caught
TaylorFries Aug 22, 2024
0aaf150
Show error message with import result.
dbarkowsky Aug 23, 2024
bf4fd88
Update importResult test factory
dbarkowsky Aug 26, 2024
d952d93
fixes to test
TaylorFries Aug 26, 2024
b93594d
lint fix
TaylorFries Aug 26, 2024
5a2b3ea
adding more tests
TaylorFries Aug 26, 2024
e53e4dd
Merge branch 'main' into bulkupload-classification-quick-fix
dbarkowsky Aug 26, 2024
898518a
type ImportRow, added factory
dbarkowsky Aug 26, 2024
8f34785
first test for makeBuildingUpsertObject
dbarkowsky Aug 26, 2024
5bb522b
small test adjustment
dbarkowsky Aug 26, 2024
ad26217
remove generateBuildingName
TaylorFries Aug 27, 2024
35a7356
remove name from test return
TaylorFries Aug 27, 2024
66e9292
Merge branch 'main' into bulkupload-classification-quick-fix
TaylorFries Aug 27, 2024
3f7c3ed
include name so building can insert
dbarkowsky Aug 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 156 additions & 65 deletions express-api/src/services/properties/propertiesServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ProjectStatus } from '@/constants/projectStatus';
import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty';
import { ProjectStatus as ProjectStatusEntity } from '@/typeorm/Entities/ProjectStatus';
import { parentPort } from 'worker_threads';
import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode';

/**
* Perform a fuzzy search for properties based on the provided keyword.
Expand Down Expand Up @@ -215,19 +216,6 @@ const getPropertiesForMap = async (filter?: MapFilter) => {
return properties;
};

/**
* Generates a building name based on the provided parameters.
* @param name - The name of the building.
* @param desc - The description of the building.
* @param localId - The local ID of the building.
* @returns The generated building name.
*/
const generateBuildingName = (name: string, desc: string = null, localId: string = null) => {
return (
(localId == null ? '' : localId) +
(name != null ? name : desc?.substring(0, 150 < desc.length ? 150 : desc.length).trim())
);
};
const numberOrNull = (value: any) => {
if (value == '' || value == null) return null;
return typeof value === 'number' ? value : Number(value.replace?.(/-/g, ''));
Expand Down Expand Up @@ -269,17 +257,16 @@ export const getClassificationOrThrow = (
row: Record<string, any>,
classifications: PropertyClassification[],
) => {
let classificationId: number = null;
if (compareWithoutCase(String(row.Status), 'Active')) {
let classificationId: number;
if (row.Classification) {
classificationId = classifications.find((a) =>
compareWithoutCase(row.Classification, a.Name),
)?.Id;
if (classificationId == null)
throw new Error(`Classification "${row.Classification}" is not supported.`);
} else {
classificationId = classifications.find((a) => a.Name === 'Disposed')?.Id;
if (classificationId == null) throw new Error(`Unable to classify this parcel.`);
throw new Error(`Unable to classify this parcel.`);
}
if (classificationId == null)
throw new Error(`Classification "${row.Classification}" is not supported.`);
return classificationId;
};

Expand Down Expand Up @@ -363,6 +350,16 @@ const compareWithoutCase = (str1: string, str2: string) => {
else return false;
};

export const setNewBool = (newValue: boolean, previousValue: boolean, defaultValue: boolean) => {
let returnValue = defaultValue;
if (newValue == true || newValue == false) {
returnValue = newValue;
} else if (previousValue == true || previousValue == false) {
returnValue = previousValue;
}
return returnValue;
};

/**
* Creates an object for upserting a parcel entity with the provided data.
* @param row - The row data containing the parcel information.
Expand Down Expand Up @@ -411,33 +408,37 @@ const makeParcelUpsertObject = async (
CreatedOn: new Date(),
});
}

const classificationId: number = getClassificationOrThrow(row, lookups.classifications);

const adminAreaId: number = getAdministrativeAreaOrThrow(row, lookups.adminAreas);

const pin = numberOrNull(row.PIN) ?? existentParcel?.PIN;
const description = row.Description ?? (existentParcel ? existentParcel.Description : '');
const isSensitive = setNewBool(row.IsSensitive, existentParcel?.IsSensitive, false);
const isVisibleToOtherAgencies = setNewBool(
row.IsVisibleToOtherAgencies,
existentParcel?.IsVisibleToOtherAgencies,
false,
);
return {
Id: existentParcel?.Id,
AgencyId: getAgencyOrThrowIfMismatched(row, lookups, roles).Id,
PID: numberOrNull(row.PID),
PIN: numberOrNull(row.PIN),
PIN: pin,
ClassificationId: classificationId,
Name: row.Name,
CreatedById: existentParcel ? undefined : user.Id,
CreatedById: existentParcel ? existentParcel.CreatedById : user.Id,
UpdatedById: existentParcel ? user.Id : undefined,
UpdatedOn: existentParcel ? new Date() : undefined,
CreatedOn: existentParcel ? undefined : new Date(),
CreatedOn: existentParcel ? existentParcel.CreatedOn : new Date(),
Location: {
x: row.Longitude,
y: row.Latitude,
},
Address1: row.Address,
Address1: row.Address ?? existentParcel?.Address1 ?? null,
AdministrativeAreaId: adminAreaId,
IsSensitive: false,
IsVisibleToOtherAgencies: true,
IsSensitive: isSensitive,
IsVisibleToOtherAgencies: isVisibleToOtherAgencies,
PropertyTypeId: 0,
Description: row.Description,
LandArea: numberOrNull(row.LandArea),
Description: description,
LandArea: numberOrNull(row.LandArea) ?? existentParcel ? existentParcel.LandArea : null,
Evaluations: currRowEvaluations,
Fiscals: currRowFiscals,
};
Expand Down Expand Up @@ -498,31 +499,45 @@ const makeBuildingUpsertObject = async (
const predominateUseId = getBuildingPredominateUseOrThrow(row, lookups.predominateUses);
const adminAreaId = getAdministrativeAreaOrThrow(row, lookups.adminAreas);

const description = row.Description ?? (existentBuilding ? existentBuilding.Description : '');
const rentableArea = row.NetUsableArea ?? (existentBuilding ? existentBuilding.RentableArea : 0);
const isSensitive = setNewBool(row.IsSensitive, existentBuilding?.IsSensitive, false);
const isVisibleToOtherAgencies = setNewBool(
row.IsVisibleToOtherAgencies,
existentBuilding?.IsVisibleToOtherAgencies,
false,
);
const buildingFloorCount =
row.BuildingFloorCount ?? (existentBuilding ? existentBuilding.BuildingFloorCount : 0);
const tenancy = row.BuildingTenancy ?? (existentBuilding ? existentBuilding.BuildingTenancy : '');
const totalArea = row.TotalArea ?? (existentBuilding ? existentBuilding.TotalArea : 0);

return {
Id: existentBuilding?.Id,
PID: numberOrNull(row.PID),
PIN: numberOrNull(row.PIN),
PIN: numberOrNull(row.PIN) ?? existentBuilding?.PIN ?? null,
AgencyId: getAgencyOrThrowIfMismatched(row, lookups, roles).Id,
ClassificationId: classificationId,
BuildingConstructionTypeId: constructionTypeId,
BuildingPredominateUseId: predominateUseId,
Name: generateBuildingName(row.Name, row.Description, row.LocalId),
CreatedById: existentBuilding ? undefined : user.Id,
CreatedById: existentBuilding ? existentBuilding.CreatedById : user.Id,
UpdatedById: existentBuilding ? user.Id : undefined,
UpdatedOn: existentBuilding ? new Date() : undefined,
CreatedOn: existentBuilding ? undefined : new Date(),
CreatedOn: existentBuilding ? existentBuilding.CreatedOn : new Date(),
Location: {
x: row.Longitude,
y: row.Latitude,
},
AdministrativeAreaId: adminAreaId,
IsSensitive: false,
IsVisibleToOtherAgencies: true,
PropertyTypeId: 0,
RentableArea: numberOrNull(row.RentableArea) ?? 0,
BuildingTenancy: row.Tenancy,
BuildingFloorCount: 0,
TotalArea: 0,
IsSensitive: isSensitive,
Description: description,
Address1: row.Address ?? existentBuilding?.Address1 ?? null,
IsVisibleToOtherAgencies: isVisibleToOtherAgencies,
PropertyTypeId: 1,
RentableArea: rentableArea,
BuildingTenancy: tenancy,
BuildingFloorCount: buildingFloorCount,
TotalArea: totalArea,
Evaluations: currRowEvaluations,
Fiscals: currRowFiscals,
};
Expand All @@ -543,6 +558,62 @@ export type BulkUploadRowResult = {
reason?: string;
};

export const checkForHeaders = (sheetObj: Record<string, any>[], columnArray: any) => {
const requiredHeaders = [
'PropertyType',
'PID',
'Classification',
'AgencyCode',
'AdministrativeArea',
'Latitude',
'Longitude',
];
for (let rowNum = 0; rowNum < sheetObj.length; rowNum++) {
const row = sheetObj[rowNum];
if (row.PropertyType == 'Building') {
requiredHeaders.push('Name', 'PredominateUse', 'ConstructionType');
break;
}
}
for (let rowNum = 0; rowNum < requiredHeaders.length; rowNum++) {
if (!columnArray.includes(requiredHeaders[rowNum])) {
throw new ErrorWithCode(`Missing required header: ${requiredHeaders[rowNum]}`, 400);
}
dbarkowsky marked this conversation as resolved.
Show resolved Hide resolved
}
};

export interface ImportRow {
// Required
PropertyType: 'Land' | 'Building';
PID: number;
Classification: string;
AgencyCode: string;
AdministrativeArea: string;
Latitude: number;
Longitude: number;
// Required for Buildings
ConstructionType?: string;
PredominateUse?: string;
Name?: string;
// Optional
Description?: string;
Address?: string;
PIN?: number;
Assessed?: number;
Netbook?: number;
FiscalYear?: number;
AssessedYear?: number;
IsSensitive?: boolean;
IsVisibleToOtherAgencies?: boolean; // TODO: Removed in other PR.
LandArea?: number;
BuildingTenancy?: number;
NetUsableArea?: number;
BuildingFloorCount?: number;
TotalArea?: number;
// Not displayed in UI
LocalId?: string;
}

/**
* Imports properties data from a worksheet as JSON format, processes each row to upsert parcels or buildings,
* and returns an array of BulkUploadRowResult indicating the actions taken for each row.
Expand All @@ -558,7 +629,11 @@ const importPropertiesAsJSON = async (
roles: string[],
resultId: number,
) => {
const sheetObj: Record<string, any>[] = xlsx.utils.sheet_to_json(worksheet);
const columnsArray = xlsx.utils.sheet_to_json(worksheet, { header: 1 })[0];
const sheetObj: ImportRow[] = xlsx.utils.sheet_to_json(worksheet);

checkForHeaders(sheetObj, columnsArray);

const classifications = await AppDataSource.getRepository(PropertyClassification).find({
select: { Name: true, Id: true },
});
Expand All @@ -584,8 +659,6 @@ const importPropertiesAsJSON = async (
userAgencies,
};
const results: Array<BulkUploadRowResult> = [];
// let queuedParcels = [];
// let queuedBuildings = [];
const queryRunner = AppDataSource.createQueryRunner();
try {
for (let rowNum = 0; rowNum < sheetObj.length; rowNum++) {
Expand All @@ -603,31 +676,39 @@ const importPropertiesAsJSON = async (
queryRunner,
existentParcel,
);
//queuedParcels.push(parcelToUpsert);
await queryRunner.manager.save(Parcel, parcelToUpsert);
results.push({ action: existentParcel ? 'updated' : 'inserted', rowNumber: rowNum });
} catch (e) {
results.push({ action: 'error', reason: e.message, rowNumber: rowNum });
}
} else if (row.PropertyType === 'Building') {
const generatedName = generateBuildingName(row.Name, row.Description, row.LocalId);
const existentBuilding = await queryRunner.manager.findOne(Building, {
where: { PID: numberOrNull(row.PID), Name: generatedName },
const foundBuildings = await queryRunner.manager.findAndCount(Building, {
where: { PID: numberOrNull(row.PID), Name: row.Name },
});
try {
const buildingForUpsert = await makeBuildingUpsertObject(
row,
user,
roles,
lookups,
queryRunner,
existentBuilding,
);
//queuedBuildings.push(buildingForUpsert);
await queryRunner.manager.save(Building, buildingForUpsert);
results.push({ action: existentBuilding ? 'updated' : 'inserted', rowNumber: rowNum });
} catch (e) {
results.push({ action: 'error', reason: e.message, rowNumber: rowNum });
const count = foundBuildings[1];
if (count > 1) {
results.push({
action: 'error',
reason: 'Multiple buildings match PID, Name combo.',
rowNumber: rowNum,
});
} else {
const existentBuilding = foundBuildings[0][0];
try {
const buildingForUpsert = await makeBuildingUpsertObject(
row,
user,
roles,
lookups,
queryRunner,
existentBuilding,
);
//queuedBuildings.push(buildingForUpsert);
await queryRunner.manager.save(Building, buildingForUpsert);
results.push({ action: existentBuilding ? 'updated' : 'inserted', rowNumber: rowNum });
} catch (e) {
results.push({ action: 'error', reason: e.message, rowNumber: rowNum });
}
}
} else {
results.push({
Expand Down Expand Up @@ -877,18 +958,26 @@ const processFile = async (filePath: string, resultRowId: number, user: User, ro
const worksheet = file.Sheets[sheetName];

results = await propertyServices.importPropertiesAsJSON(worksheet, user, roles, resultRowId);
await AppDataSource.getRepository(ImportResult).save({
Id: resultRowId,
CompletionPercentage: 1.0,
Results: results,
UpdatedById: user.Id,
UpdatedOn: new Date(),
});
return results; // Note that this return still works with finally as long as return is not called from finally block.
} catch (e) {
parentPort.postMessage('Aborting file upload: ' + e.message);
parentPort.postMessage('Aborting stack: ' + e.stack);
} finally {
await AppDataSource.getRepository(ImportResult).save({
Id: resultRowId,
CompletionPercentage: 1.0,
CompletionPercentage: -1.0,
Results: results,
UpdatedById: user.Id,
UpdatedOn: new Date(),
Message: e.message,
});
} finally {
await AppDataSource.destroy(); //Not sure whether this is necessary but seems like the safe thing to do.
}
};
Expand All @@ -902,6 +991,8 @@ const propertyServices = {
getPropertiesForExport,
processFile,
findLinkedProjectsForProperty,
makeBuildingUpsertObject,
makeParcelUpsertObject,
};

export default propertyServices;
3 changes: 3 additions & 0 deletions express-api/src/typeorm/Entities/ImportResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,7 @@ export class ImportResult extends SoftDeleteEntity {

@Column({ name: 'results', type: 'jsonb', nullable: true })
Results: BulkUploadRowResult[];

@Column({ type: 'character varying', length: 250, name: 'message', nullable: true })
Message: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddMessageToImport1724454425646 implements MigrationInterface {
name = 'AddMessageToImport1724454425646';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "import_result" ADD "message" character varying(250)`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "import_result" DROP COLUMN "message"`);
}
}
Loading
Loading