Merge pull request #339 from SyncrowIOT/feat/commission-device

Feat/commission device
This commit is contained in:
hannathkadher
2025-04-22 11:04:15 +04:00
committed by GitHub
17 changed files with 495 additions and 66 deletions
@@ -19,6 +19,8 @@ import {
SceneRepository,
} from '@app/common/modules/scene/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository';
@Module({
imports: [ConfigModule, SpaceRepositoryModule],
@@ -39,6 +41,8 @@ import { AutomationRepository } from '@app/common/modules/automation/repositorie
SceneIconRepository,
SceneRepository,
AutomationRepository,
CommunityRepository,
SubspaceRepository,
],
exports: [],
})
@@ -22,9 +22,10 @@ import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { CommissionDeviceCsvDto } from '../dto';
import { CommunityParam } from '@app/common/dto/community-space.param';
import { DeviceCommissionService } from '../services';
import { ProjectParam } from '@app/common/dto/project-param.dto';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
@ApiTags('Commission Devices Module')
@Controller({
@@ -34,6 +35,8 @@ import { ProjectParam } from '@app/common/dto/project-param.dto';
export class DeviceCommissionController {
constructor(private readonly commissionService: DeviceCommissionService) {}
@UseGuards(PermissionsGuard)
@Permissions('COMMISSION_DEVICE')
@ApiBearerAuth()
@Post()
@ApiConsumes('multipart/form-data')
@@ -65,7 +68,7 @@ export class DeviceCommissionController {
@Param() param: ProjectParam,
@Req() req: any,
): Promise<BaseResponseDto> {
await this.commissionService.processCsv(file.path);
await this.commissionService.processCsv(param, file.path);
return {
message: 'CSV file received and processing started',
success: true,
@@ -2,44 +2,185 @@ import * as fs from 'fs';
import * as csv from 'csv-parser';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { Injectable } from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { DeviceService } from 'src/device/services';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { SpaceRepository } from '@app/common/modules/space';
import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository';
import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { ProjectParam } from '@app/common/dto/project-param.dto';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
@Injectable()
export class DeviceCommissionService {
constructor(
private readonly tuyaService: TuyaService,
private readonly deviceService: DeviceService,
private readonly communityRepository: CommunityRepository,
private readonly spaceRepository: SpaceRepository,
private readonly subspaceRepository: SubspaceRepository,
private readonly deviceRepository: DeviceRepository,
private readonly projectRepository: ProjectRepository,
) {}
async processCsv(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const results = [];
async processCsv(param: ProjectParam, filePath: string) {
const successCount = { value: 0 };
const failureCount = { value: 0 };
fs.createReadStream(filePath)
.pipe(csv())
.on('data', async (row) => {
console.log(`Device: ${JSON.stringify(row)}`);
const deviceId = row.deviceId?.trim();
const projectId = param.projectUuid;
if (!deviceId) {
console.error('Missing deviceId or deviceName in row:', row);
return;
} else {
const device = await this.tuyaService.getDeviceDetails(
row.deviceId,
);
console.log(device);
}
})
.on('end', () => {
console.log(`Finished processing ${results.length} devices.`);
resolve();
})
.on('error', (error) => {
console.error('Error reading CSV', error);
reject(error);
});
const project = await this.projectRepository.findOne({
where: { uuid: projectId },
});
if (!project) {
throw new HttpException('Project not found', HttpStatus.NOT_FOUND);
}
const rows: any[] = [];
try {
await new Promise<void>((resolve, reject) => {
fs.createReadStream(filePath)
.pipe(csv())
.on('data', (row) => rows.push(row))
.on('end', () => resolve())
.on('error', (error) => reject(error));
});
for (const row of rows) {
await this.processCsvRow(param, row, successCount, failureCount);
}
return new SuccessResponseDto({
message: `Successfully processed CSV file`,
data: {
successCount: successCount.value,
failureCount: failureCount.value,
},
statusCode: HttpStatus.ACCEPTED,
});
} catch (error) {
throw new HttpException(
'Failed to process CSV file',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async processCsvRow(
param: ProjectParam,
row: any,
successCount: { value: number },
failureCount: { value: number },
) {
try {
const rawDeviceId = row['Device ID']?.trim();
const communityId = row['Community UUID']?.trim();
const spaceId = row['Space UUID']?.trim();
const subspaceId = row['Subspace UUID']?.trim();
const tagName = row['Tag']?.trim();
const productName = row['Product Name']?.trim();
const projectId = param.projectUuid;
if (!rawDeviceId) {
console.error('Missing Device ID in row:', row);
failureCount.value++;
return;
}
const device = await this.tuyaService.getDeviceDetails(rawDeviceId);
if (!device) {
console.error(`Device not found for Device ID: ${rawDeviceId}`);
failureCount.value++;
return;
}
const community = await this.communityRepository.findOne({
where: { uuid: communityId, project: { uuid: projectId } },
});
if (!community) {
console.error(`Community not found: ${communityId}`);
failureCount.value++;
return;
}
const tuyaSpaceId = community.externalId;
const space = await this.spaceRepository.findOne({
where: { uuid: spaceId },
relations: [
'productAllocations',
'productAllocations.tags',
'productAllocations.product',
],
});
if (!space) {
console.error(`Space not found: ${spaceId}`);
failureCount.value++;
return;
}
let subspace: SubspaceEntity | null = null;
if (subspaceId?.trim()) {
subspace = await this.subspaceRepository.findOne({
where: { uuid: subspaceId },
relations: [
'productAllocations',
'productAllocations.tags',
'productAllocations.product',
],
});
if (!subspace) {
console.error(`Subspace not found: ${subspaceId}`);
failureCount.value++;
return;
}
}
const allocations =
subspace?.productAllocations || space.productAllocations;
const match = allocations
.flatMap((pa) =>
(pa.tags || []).map((tag) => ({ product: pa.product, tag })),
)
.find(({ tag }) => tag.name === tagName);
if (!match) {
console.error(`No matching tag found for Device ID: ${rawDeviceId}`);
failureCount.value++;
return;
}
if (match.product.name !== productName) {
console.error(`Product name mismatch for Device ID: ${rawDeviceId}`);
failureCount.value++;
return;
}
const middlewareDevice = this.deviceRepository.create({
deviceTuyaUuid: rawDeviceId,
isActive: true,
spaceDevice: space,
subspace: subspace || null,
productDevice: match.product,
tag: match.tag,
});
await this.deviceRepository.save(middlewareDevice);
await this.deviceService.transferDeviceInSpacesTuya(
rawDeviceId,
tuyaSpaceId,
);
successCount.value++;
} catch (err) {
failureCount.value++;
}
}
}
+41 -1
View File
@@ -4,13 +4,23 @@ import {
Controller,
Delete,
Get,
Header,
HttpStatus,
Param,
Post,
Put,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import {
ApiBearerAuth,
ApiOperation,
ApiProduces,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { ProjectService } from '../services';
import { CreateProjectDto, GetProjectParam } from '../dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@@ -86,4 +96,34 @@ export class ProjectController {
async findOne(@Param() params: GetProjectParam): Promise<BaseResponseDto> {
return this.projectService.getProject(params.projectUuid);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: ControllerRoute.PROJECT.ACTIONS.EXPORT_STRUCTURE_CSV_SUMMARY,
description:
ControllerRoute.PROJECT.ACTIONS.EXPORT_STRUCTURE_CSV_DESCRIPTION,
})
@Get(':projectUuid/structure/export-csv')
@ApiProduces('text/csv')
@ApiResponse({
status: 200,
description:
'A CSV file containing project details and their structural hierarchy (Project, Community, Space, Parent Space).',
})
@Header('Content-Type', 'text/csv')
@Header('Content-Disposition', 'attachment; filename=project-structure.csv')
async exportStructures(
@Param() params: GetProjectParam,
@Res() res: Response,
): Promise<void> {
try {
const csvStream = await this.projectService.exportToCsv(params);
csvStream.pipe(res as unknown as NodeJS.WritableStream);
} catch (error) {
res
.status(error.status || HttpStatus.INTERNAL_SERVER_ERROR)
.json({ message: error.message || 'Failed to generate CSV file.' });
}
}
}
+83 -1
View File
@@ -4,7 +4,13 @@ import { ProjectController } from './controllers';
import { ProjectService } from './services';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { CreateOrphanSpaceHandler } from './handler';
import { SpaceRepository } from '@app/common/modules/space';
import {
InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository,
SpaceRepository,
TagRepository,
} from '@app/common/modules/space';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { InviteUserRepository } from '@app/common/modules/Invite-user/repositiories';
import { ProjectUserController } from './controllers/project-user.controller';
@@ -13,6 +19,47 @@ import {
UserRepository,
UserSpaceRepository,
} from '@app/common/modules/user/repositories';
import {
SpaceLinkService,
SpaceService,
SubspaceDeviceService,
SubSpaceService,
ValidationService,
} from 'src/space/services';
import { TagService } from 'src/tags/services';
import {
SpaceModelService,
SubSpaceModelService,
} from 'src/space-model/services';
import { DeviceService } from 'src/device/services';
import { SpaceProductAllocationService } from 'src/space/services/space-product-allocation.service';
import {
SubspaceProductAllocationRepository,
SubspaceRepository,
} from '@app/common/modules/space/repositories/subspace.repository';
import { SubspaceProductAllocationService } from 'src/space/services/subspace/subspace-product-allocation.service';
import { CommunityService } from 'src/community/services';
import {
SpaceModelProductAllocationRepoitory,
SpaceModelRepository,
SubspaceModelProductAllocationRepoitory,
SubspaceModelRepository,
} from '@app/common/modules/space-model';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { NewTagRepository } from '@app/common/modules/tag/repositories/tag-repository';
import { ProductRepository } from '@app/common/modules/product/repositories';
import { SpaceModelProductAllocationService } from 'src/space-model/services/space-model-product-allocation.service';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { SceneService } from 'src/scene/services';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { SubspaceModelProductAllocationService } from 'src/space-model/services/subspace/subspace-model-product-allocation.service';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
import {
SceneIconRepository,
SceneRepository,
} from '@app/common/modules/scene/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
const CommandHandlers = [CreateOrphanSpaceHandler];
@@ -30,6 +77,41 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
InviteUserRepository,
UserSpaceRepository,
UserRepository,
SpaceService,
InviteSpaceRepository,
SpaceLinkService,
SubSpaceService,
ValidationService,
TagService,
SpaceModelService,
DeviceService,
SpaceProductAllocationService,
SpaceLinkRepository,
SubspaceRepository,
SubspaceDeviceService,
SubspaceProductAllocationService,
CommunityService,
SpaceModelRepository,
DeviceRepository,
NewTagRepository,
ProductRepository,
SubSpaceModelService,
SpaceModelProductAllocationService,
SpaceProductAllocationRepository,
SubspaceProductAllocationRepository,
SceneDeviceRepository,
DeviceStatusFirebaseService,
SceneService,
TuyaService,
TagRepository,
SubspaceModelRepository,
SubspaceModelProductAllocationService,
SpaceModelProductAllocationRepoitory,
DeviceStatusLogRepository,
SceneIconRepository,
SceneRepository,
AutomationRepository,
SubspaceModelProductAllocationRepoitory,
],
exports: [ProjectService, CqrsModule],
})
+140 -2
View File
@@ -1,6 +1,12 @@
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateProjectDto } from '../dto';
import {
forwardRef,
HttpException,
HttpStatus,
Inject,
Injectable,
} from '@nestjs/common';
import { CreateProjectDto, GetProjectParam } from '../dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { ProjectEntity } from '@app/common/modules/project/entities';
@@ -13,6 +19,11 @@ import { PageResponse } from '@app/common/dto/pagination.response.dto';
import { CommandBus } from '@nestjs/cqrs';
import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command';
import { UserRepository } from '@app/common/modules/user/repositories';
import { format } from '@fast-csv/format';
import { PassThrough } from 'stream';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceService } from 'src/space/services';
import { ORPHAN_COMMUNITY_NAME } from '@app/common/constants/orphan-constant';
@Injectable()
export class ProjectService {
@@ -20,6 +31,8 @@ export class ProjectService {
private readonly projectRepository: ProjectRepository,
private readonly userRepository: UserRepository,
private commandBus: CommandBus,
@Inject(forwardRef(() => SpaceService))
private readonly spaceService: SpaceService,
) {}
async createProject(
@@ -213,4 +226,129 @@ export class ProjectService {
async validate(name: string): Promise<boolean> {
return await this.projectRepository.exists({ where: { name } });
}
async exportToCsv(param: GetProjectParam): Promise<PassThrough> {
const project = await this.projectRepository.findOne({
where: { uuid: param.projectUuid },
relations: [
'communities',
'communities.spaces',
'communities.spaces.parent',
'communities.spaces.productAllocations',
'communities.spaces.productAllocations.product',
'communities.spaces.productAllocations.tags',
'communities.spaces.subspaces',
'communities.spaces.subspaces.productAllocations',
'communities.spaces.subspaces.productAllocations.product',
'communities.spaces.subspaces.productAllocations.tags',
],
});
if (!project) {
throw new HttpException(
`Project with UUID ${param.projectUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
const stream = new PassThrough();
const csvStream = format({
headers: [
'Device ID',
'Community Name',
'Space Name',
'Space Location',
'Subspace Name',
'Tag',
'Product Name',
'Community UUID',
'Space UUID',
'Subspace UUID',
],
});
csvStream.pipe(stream);
const allSpaces: SpaceEntity[] = [];
for (const community of project.communities) {
if (community.name === `${ORPHAN_COMMUNITY_NAME}-${project.name}`)
continue;
for (const space of community.spaces) {
if (!space.disabled) {
space.community = community;
allSpaces.push(space);
}
}
}
const spaceMap = new Map<string, SpaceEntity>();
for (const space of allSpaces) {
spaceMap.set(space.uuid, space);
}
function buildSpaceLocation(space: SpaceEntity): string {
const path: string[] = [];
let current = space.parent;
while (current) {
path.unshift(current.spaceName);
current = current.parent ?? spaceMap.get(current.uuid)?.parent ?? null;
}
return path.join(' > ');
}
for (const space of allSpaces) {
const spaceLocation = buildSpaceLocation(space);
for (const subspace of space.subspaces || []) {
if (subspace.disabled) continue;
for (const productAllocation of subspace.productAllocations || []) {
for (const tag of productAllocation.tags || []) {
csvStream.write({
'Device ID': '',
'Community Name': space.community?.name || '',
'Space Name': space.spaceName,
'Space Location': spaceLocation,
'Subspace Name': subspace.subspaceName || '',
Tag: tag.name,
'Product Name': productAllocation.product.name || '',
'Community UUID': space.community?.uuid || '',
'Space UUID': space.uuid,
'Subspace UUID': subspace.uuid,
});
}
}
}
for (const productAllocation of space.productAllocations || []) {
for (const tag of productAllocation.tags || []) {
csvStream.write({
'Device ID': '',
'Community Name': space.community?.name || '',
'Space Name': space.spaceName,
'Space Location': spaceLocation,
'Subspace Name': '',
Tag: tag.name,
'Product Name': productAllocation.product.name || '',
'Community UUID': space.community?.uuid || '',
'Space UUID': space.uuid,
'Subspace UUID': '',
});
}
}
}
csvStream.end();
return stream;
}
getSpaceLocation(space: SpaceEntity): string {
const names = [];
let current = space.parent;
while (current) {
names.unshift(current.spaceName);
current = current.parent;
}
return names.join(' > ');
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ export class DisableSpaceHandler
}
}
const tagUuids = space.tags?.map((tag) => tag.uuid) || [];
const tagUuids = space.productAllocations?.map((tag) => tag.uuid) || [];
/* const subspaceDtos =
space.subspaces?.map((subspace) => ({
subspaceUuid: subspace.uuid,
+2 -2
View File
@@ -582,8 +582,8 @@ export class SpaceService {
queryRunner: QueryRunner,
): Promise<void> {
try {
if (space.subspaces || space.tags) {
if (space.tags) {
if (space.subspaces || space.productAllocations) {
if (space.productAllocations) {
await this.spaceProductAllocationService.unlinkModels(
space,
queryRunner,
@@ -18,7 +18,6 @@ import { In, QueryRunner } from 'typeorm';
import { SubspaceModelEntity } from '@app/common/modules/space-model';
import { ValidationService } from '../space-validation.service';
import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository';
import { TagService } from '../tag';
import { ModifyAction } from '@app/common/constants/modify-action.enum';
import { SubspaceDeviceService } from './subspace-device.service';
import { ModifyTagDto } from 'src/space/dtos/tag/modify-tag.dto';
@@ -34,7 +33,6 @@ export class SubSpaceService {
constructor(
private readonly subspaceRepository: SubspaceRepository,
private readonly validationService: ValidationService,
private readonly tagService: TagService,
private readonly newTagService: NewTagService,
public readonly deviceService: SubspaceDeviceService,
private readonly subspaceProductAllocationService: SubspaceProductAllocationService,
@@ -481,21 +479,6 @@ export class SubSpaceService {
{ disabled: true },
);
if (subspace.tags?.length) {
const modifyTagDtos: ProcessTagDto[] = subspace.tags.map((tag) => ({
uuid: tag.uuid,
action: ModifyAction.ADD,
name: tag.tag,
productUuid: tag.product.uuid,
}));
await this.tagService.moveTags(
modifyTagDtos,
queryRunner,
subspace.space,
null,
);
}
if (subspace.devices.length > 0) {
await this.deviceService.deleteSubspaceDevices(
subspace.devices,
+2 -4
View File
@@ -118,7 +118,7 @@ export class TagService {
await queryRunner.manager.update(
this.tagRepository.target,
{ uuid: tag.uuid },
{ subspace, space: null },
{ subspace },
);
tag.subspace = subspace;
}
@@ -127,10 +127,9 @@ export class TagService {
await queryRunner.manager.update(
this.tagRepository.target,
{ uuid: tag.uuid },
{ subspace: null, space: space },
{ subspace: null },
);
tag.subspace = null;
tag.space = space;
}
return tag;
@@ -367,7 +366,6 @@ export class TagService {
where: [
{
tag,
space: { uuid: spaceUuid },
product: { uuid: productUuid },
disabled: false,
},