- 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>
436 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|