erp-core-backend/src/modules/branches/services/branches.service.ts
rckrdmrd ca07b4268d feat: Add complete module structure for ERP backend
- Add modules: ai, audit, billing-usage, biometrics, branches, dashboard,
  feature-flags, invoices, mcp, mobile, notifications, partners,
  payment-terminals, products, profiles, purchases, reports, sales,
  storage, warehouses, webhooks, whatsapp
- Add controllers, DTOs, entities, and services for each module
- Add shared services and utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:40:54 -06:00

436 lines
14 KiB
TypeScript

import { Repository, FindOptionsWhere, ILike, IsNull, In } from 'typeorm';
import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from '../entities';
import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto';
export interface BranchSearchParams {
search?: string;
branchType?: string;
isActive?: boolean;
parentId?: string;
includeChildren?: boolean;
limit?: number;
offset?: number;
}
export class BranchesService {
constructor(
private readonly branchRepository: Repository<Branch>,
private readonly assignmentRepository: Repository<UserBranchAssignment>,
private readonly scheduleRepository: Repository<BranchSchedule>,
private readonly terminalRepository: Repository<BranchPaymentTerminal>
) {}
// ============================================
// BRANCH CRUD
// ============================================
async findAll(tenantId: string, params: BranchSearchParams = {}): Promise<{ data: Branch[]; total: number }> {
const { search, branchType, isActive, parentId, limit = 50, offset = 0 } = params;
const where: FindOptionsWhere<Branch> = { tenantId };
if (branchType) where.branchType = branchType as any;
if (isActive !== undefined) where.isActive = isActive;
if (parentId) where.parentId = parentId;
if (parentId === null) where.parentId = IsNull();
const queryBuilder = this.branchRepository
.createQueryBuilder('branch')
.where('branch.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('branch.schedules', 'schedules')
.leftJoinAndSelect('branch.paymentTerminals', 'terminals');
if (search) {
queryBuilder.andWhere('(branch.name ILIKE :search OR branch.code ILIKE :search OR branch.city ILIKE :search)', {
search: `%${search}%`,
});
}
if (branchType) {
queryBuilder.andWhere('branch.branch_type = :branchType', { branchType });
}
if (isActive !== undefined) {
queryBuilder.andWhere('branch.is_active = :isActive', { isActive });
}
if (parentId) {
queryBuilder.andWhere('branch.parent_id = :parentId', { parentId });
} else if (parentId === null) {
queryBuilder.andWhere('branch.parent_id IS NULL');
}
queryBuilder.orderBy('branch.hierarchy_path', 'ASC').addOrderBy('branch.name', 'ASC');
const total = await queryBuilder.getCount();
const data = await queryBuilder.skip(offset).take(limit).getMany();
return { data, total };
}
async findOne(id: string): Promise<Branch | null> {
return this.branchRepository.findOne({
where: { id },
relations: ['parent', 'children', 'schedules', 'paymentTerminals', 'userAssignments'],
});
}
async findByCode(tenantId: string, code: string): Promise<Branch | null> {
return this.branchRepository.findOne({
where: { tenantId, code },
relations: ['schedules', 'paymentTerminals'],
});
}
async create(tenantId: string, dto: CreateBranchDto, createdBy?: string): Promise<Branch> {
// Check for duplicate code
const existing = await this.findByCode(tenantId, dto.code);
if (existing) {
throw new Error(`Branch with code '${dto.code}' already exists`);
}
// Build hierarchy path
let hierarchyPath = `/${dto.code}`;
let hierarchyLevel = 0;
if (dto.parentId) {
const parent = await this.findOne(dto.parentId);
if (!parent) {
throw new Error('Parent branch not found');
}
hierarchyPath = `${parent.hierarchyPath}/${dto.code}`;
hierarchyLevel = parent.hierarchyLevel + 1;
}
const branch = this.branchRepository.create({
...dto,
tenantId,
hierarchyPath,
hierarchyLevel,
createdBy,
});
return this.branchRepository.save(branch);
}
async update(id: string, dto: UpdateBranchDto, updatedBy?: string): Promise<Branch | null> {
const branch = await this.findOne(id);
if (!branch) return null;
// If changing parent, update hierarchy
if (dto.parentId !== undefined && dto.parentId !== branch.parentId) {
if (dto.parentId) {
const newParent = await this.findOne(dto.parentId);
if (!newParent) {
throw new Error('New parent branch not found');
}
// Check for circular reference
if (newParent.hierarchyPath.includes(`/${branch.code}/`) || newParent.id === branch.id) {
throw new Error('Cannot create circular reference in branch hierarchy');
}
branch.hierarchyPath = `${newParent.hierarchyPath}/${branch.code}`;
branch.hierarchyLevel = newParent.hierarchyLevel + 1;
} else {
branch.hierarchyPath = `/${branch.code}`;
branch.hierarchyLevel = 0;
}
// Update children hierarchy paths
await this.updateChildrenHierarchy(branch);
}
Object.assign(branch, dto, { updatedBy });
return this.branchRepository.save(branch);
}
private async updateChildrenHierarchy(parent: Branch): Promise<void> {
const children = await this.branchRepository.find({
where: { parentId: parent.id },
});
for (const child of children) {
child.hierarchyPath = `${parent.hierarchyPath}/${child.code}`;
child.hierarchyLevel = parent.hierarchyLevel + 1;
await this.branchRepository.save(child);
await this.updateChildrenHierarchy(child);
}
}
async delete(id: string): Promise<boolean> {
const branch = await this.findOne(id);
if (!branch) return false;
// Check if has children
const childrenCount = await this.branchRepository.count({ where: { parentId: id } });
if (childrenCount > 0) {
throw new Error('Cannot delete branch with children. Delete children first or move them to another parent.');
}
await this.branchRepository.softDelete(id);
return true;
}
// ============================================
// HIERARCHY
// ============================================
async getHierarchy(tenantId: string): Promise<Branch[]> {
const branches = await this.branchRepository.find({
where: { tenantId, isActive: true },
order: { hierarchyPath: 'ASC' },
});
return this.buildTree(branches);
}
private buildTree(branches: Branch[], parentId: string | null = null): Branch[] {
return branches
.filter((b) => b.parentId === parentId)
.map((branch) => ({
...branch,
children: this.buildTree(branches, branch.id),
}));
}
async getChildren(branchId: string, recursive: boolean = false): Promise<Branch[]> {
if (!recursive) {
return this.branchRepository.find({
where: { parentId: branchId, isActive: true },
order: { name: 'ASC' },
});
}
const parent = await this.findOne(branchId);
if (!parent) return [];
return this.branchRepository
.createQueryBuilder('branch')
.where('branch.hierarchy_path LIKE :path', { path: `${parent.hierarchyPath}/%` })
.andWhere('branch.is_active = true')
.orderBy('branch.hierarchy_path', 'ASC')
.getMany();
}
async getParents(branchId: string): Promise<Branch[]> {
const branch = await this.findOne(branchId);
if (!branch || !branch.hierarchyPath) return [];
const codes = branch.hierarchyPath.split('/').filter((c) => c && c !== branch.code);
if (codes.length === 0) return [];
return this.branchRepository.find({
where: { tenantId: branch.tenantId, code: In(codes) },
order: { hierarchyLevel: 'ASC' },
});
}
// ============================================
// USER ASSIGNMENTS
// ============================================
async assignUser(tenantId: string, dto: AssignUserToBranchDto, assignedBy?: string): Promise<UserBranchAssignment> {
// Check if branch exists
const branch = await this.findOne(dto.branchId);
if (!branch || branch.tenantId !== tenantId) {
throw new Error('Branch not found');
}
// Check for existing assignment of same type
const existing = await this.assignmentRepository.findOne({
where: {
userId: dto.userId,
branchId: dto.branchId,
assignmentType: (dto.assignmentType as any) ?? 'primary',
},
});
if (existing) {
// Update existing
Object.assign(existing, {
branchRole: dto.branchRole ?? existing.branchRole,
permissions: dto.permissions ?? existing.permissions,
validUntil: dto.validUntil ? new Date(dto.validUntil) : existing.validUntil,
isActive: true,
});
return this.assignmentRepository.save(existing);
}
const assignment = this.assignmentRepository.create({
...dto,
tenantId,
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
createdBy: assignedBy,
} as any);
return this.assignmentRepository.save(assignment);
}
async unassignUser(userId: string, branchId: string): Promise<boolean> {
const result = await this.assignmentRepository.update({ userId, branchId }, { isActive: false });
return (result.affected ?? 0) > 0;
}
async getUserBranches(userId: string): Promise<Branch[]> {
const assignments = await this.assignmentRepository.find({
where: { userId, isActive: true },
relations: ['branch'],
});
return assignments.map((a) => a.branch).filter((b) => b != null);
}
async getBranchUsers(branchId: string): Promise<UserBranchAssignment[]> {
return this.assignmentRepository.find({
where: { branchId, isActive: true },
order: { branchRole: 'ASC' },
});
}
async getPrimaryBranch(userId: string): Promise<Branch | null> {
const assignment = await this.assignmentRepository.findOne({
where: { userId, assignmentType: 'primary' as any, isActive: true },
relations: ['branch'],
});
return assignment?.branch ?? null;
}
// ============================================
// GEOFENCING
// ============================================
async validateGeofence(branchId: string, latitude: number, longitude: number): Promise<{ valid: boolean; distance: number }> {
const branch = await this.findOne(branchId);
if (!branch) {
throw new Error('Branch not found');
}
if (!branch.geofenceEnabled) {
return { valid: true, distance: 0 };
}
if (!branch.latitude || !branch.longitude) {
return { valid: true, distance: 0 };
}
// Calculate distance using Haversine formula
const R = 6371000; // Earth's radius in meters
const dLat = this.toRad(latitude - branch.latitude);
const dLon = this.toRad(longitude - branch.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(branch.latitude)) * Math.cos(this.toRad(latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return {
valid: distance <= branch.geofenceRadius,
distance: Math.round(distance),
};
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
async findNearbyBranches(tenantId: string, latitude: number, longitude: number, radiusMeters: number = 5000): Promise<Branch[]> {
// Use PostgreSQL's earthdistance extension if available, otherwise calculate in app
const branches = await this.branchRepository.find({
where: { tenantId, isActive: true },
});
return branches
.filter((b) => {
if (!b.latitude || !b.longitude) return false;
const result = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
return result <= radiusMeters;
})
.sort((a, b) => {
const distA = this.calculateDistance(latitude, longitude, a.latitude!, a.longitude!);
const distB = this.calculateDistance(latitude, longitude, b.latitude!, b.longitude!);
return distA - distB;
});
}
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const dLat = this.toRad(lat2 - lat1);
const dLon = this.toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// ============================================
// SCHEDULES
// ============================================
async addSchedule(branchId: string, dto: CreateBranchScheduleDto): Promise<BranchSchedule> {
const schedule = this.scheduleRepository.create({
...dto,
branchId,
specificDate: dto.specificDate ? new Date(dto.specificDate) : undefined,
});
return this.scheduleRepository.save(schedule);
}
async getSchedules(branchId: string): Promise<BranchSchedule[]> {
return this.scheduleRepository.find({
where: { branchId, isActive: true },
order: { dayOfWeek: 'ASC', specificDate: 'ASC' },
});
}
async isOpenNow(branchId: string): Promise<boolean> {
const schedules = await this.getSchedules(branchId);
const now = new Date();
const dayOfWeek = now.getDay();
const currentTime = now.toTimeString().slice(0, 5);
// Check for specific date schedule first
const today = now.toISOString().slice(0, 10);
const specificSchedule = schedules.find((s) => s.specificDate?.toISOString().slice(0, 10) === today);
if (specificSchedule) {
return currentTime >= specificSchedule.openTime && currentTime <= specificSchedule.closeTime;
}
// Check regular schedule
const regularSchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.scheduleType === 'regular');
if (regularSchedule) {
return currentTime >= regularSchedule.openTime && currentTime <= regularSchedule.closeTime;
}
return false;
}
// ============================================
// MAIN BRANCH
// ============================================
async getMainBranch(tenantId: string): Promise<Branch | null> {
return this.branchRepository.findOne({
where: { tenantId, isMain: true, isActive: true },
relations: ['schedules', 'paymentTerminals'],
});
}
async setAsMainBranch(branchId: string): Promise<Branch | null> {
const branch = await this.findOne(branchId);
if (!branch) return null;
// Unset current main branch
await this.branchRepository.update({ tenantId: branch.tenantId, isMain: true }, { isMain: false });
// Set new main branch
branch.isMain = true;
return this.branchRepository.save(branch);
}
}