import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; import { ApiError } from '../common/errors/api-error'; import { ErrorCode } from '../common/errors/error-codes'; import { NotificationsService } from '../notifications/notifications.service'; import { ProjectActivityEntity } from '../projects/entities/project-activity.entity'; import { ProjectAccessService } from '../projects/project-access.service'; import { ProjectsRepository } from '../projects/repositories/projects.repository'; import { CreateFurnitureOptionDto, CreateFurnitureRequirementDto, CreateFurnitureScenarioDto, FurnitureDeliveryDto, FurnitureDocumentLinkDto, FurnitureExpenseDto, FurnitureListQueryDto, FurnitureOrderDto, FurnitureOptionListQueryDto, UpdateFurnitureOptionDto, UpdateFurnitureRequirementDto, UpdateFurnitureScenarioDto, UpdateFurnitureScenarioSelectionsDto, } from './dto/furniture.dto'; import { FurnitureAvailability, FurnitureDeliveryStatus, FurnitureOptionEntity, FurnitureOptionStatus, FurnitureRequirementEntity, FurnitureRequirementStatus, FurnitureScenarioEntity, FurnitureScenarioSelectionEntity, FurnitureScenarioStatus, FurnitureScenarioType, } from './entities/furniture.entities'; import { calculateFurnitureTotal, formatOptionalMoney, sumMoney, } from './furniture-pricing'; import { FurnitureRepository } from './furniture.repository'; import { RenovationRepository } from './renovation.repository'; @Injectable() export class FurnitureService { private readonly requirementSorts = new Set([ 'name', 'room', 'category', 'price', 'priority', 'status', 'deliveryDate', 'updatedAt', 'sortOrder', ]); constructor( private readonly furniture: FurnitureRepository, private readonly renovation: RenovationRepository, private readonly access: ProjectAccessService, private readonly projects: ProjectsRepository, private readonly notifications: NotificationsService, @InjectDataSource() private readonly dataSource: DataSource, ) {} async requirements( projectId: string, userId: string, query: FurnitureListQueryDto, ) { await this.access.require(projectId, userId, 'read'); if (!this.requirementSorts.has(query.sortBy)) this.validation('Dieses Sortierfeld ist nicht erlaubt.'); return this.furniture.pageRequirements(projectId, query); } async requirement(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'read'); const requirement = await this.owned( this.furniture.requirements, projectId, id, ); return { ...requirement, options: await this.furniture.listOptions(projectId, id), }; } async createRequirement( projectId: string, userId: string, dto: CreateFurnitureRequirementDto, ) { await this.access.require(projectId, userId, 'edit'); await this.validateRequirementLinks( projectId, dto.roomId, dto.responsibleUserId, ); const saved = await this.furniture.requirements.save( this.furniture.requirements.create({ projectId, roomId: dto.roomId ?? null, name: dto.name?.trim() || 'Unbenannter Möbelbedarf', description: dto.description?.trim() || null, category: dto.category, priority: dto.priority, requiredQuantity: dto.requiredQuantity, status: dto.status, responsibleUserId: dto.responsibleUserId ?? null, maximumBudget: this.decimal(dto.maximumBudget), sortOrder: dto.sortOrder ?? 0, createdByUserId: userId, }), ); await this.activity(projectId, userId, 'furniture.requirement-created', { requirementId: saved.id, roomId: saved.roomId, name: saved.name, }); if (saved.responsibleUserId && saved.responsibleUserId !== userId) await this.notifications.createForUser({ userId: saved.responsibleUserId, type: 'furniture.requirement-assigned', title: 'Möbelbedarf zugewiesen', message: `Sie sind für „${saved.name}“ verantwortlich.`, link: `/projekte/${projectId}/moebel?requirement=${saved.id}`, metadata: { projectId, requirementId: saved.id }, }); return saved; } async updateRequirement( projectId: string, id: string, userId: string, dto: UpdateFurnitureRequirementDto, ) { await this.access.require(projectId, userId, 'edit'); await this.validateRequirementLinks( projectId, dto.roomId, dto.responsibleUserId, ); const before = await this.owned(this.furniture.requirements, projectId, id); await this.versioned( this.furniture.requirements, projectId, id, dto.version, { roomId: dto.roomId ?? null, name: dto.name?.trim() || 'Unbenannter Möbelbedarf', description: dto.description?.trim() || null, category: dto.category, priority: dto.priority, requiredQuantity: dto.requiredQuantity, status: dto.status, responsibleUserId: dto.responsibleUserId ?? null, maximumBudget: this.decimal(dto.maximumBudget), sortOrder: dto.sortOrder ?? 0, }, ); await this.activity( projectId, userId, before.roomId === (dto.roomId ?? null) ? 'furniture.requirement-updated' : 'furniture.requirement-moved', { requirementId: id, roomId: dto.roomId ?? null }, ); return this.owned(this.furniture.requirements, projectId, id); } async deleteRequirement(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); const requirement = await this.owned( this.furniture.requirements, projectId, id, ); const ordered = await this.furniture.options.count({ where: { projectId, requirementId: id, status: In([ FurnitureOptionStatus.Ordered, FurnitureOptionStatus.Delivered, ]), }, }); if ( ordered > 0 && requirement.status !== FurnitureRequirementStatus.Omitted ) this.conflict( 'Bestellte Möbelbedarfe müssen zuerst als entfällt markiert und nachvollziehbar behandelt werden.', ); await this.furniture.requirements.softDelete({ id, projectId }); await this.activity(projectId, userId, 'furniture.requirement-archived', { requirementId: id, }); } async copyRequirement(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); const source = await this.owned(this.furniture.requirements, projectId, id); const saved = await this.furniture.requirements.save( this.furniture.requirements.create({ projectId, roomId: source.roomId, name: `${source.name} (Kopie)`, description: source.description, category: source.category, priority: source.priority, requiredQuantity: source.requiredQuantity, status: FurnitureRequirementStatus.Identified, responsibleUserId: source.responsibleUserId, maximumBudget: source.maximumBudget, sortOrder: source.sortOrder + 1, createdByUserId: userId, }), ); await this.activity(projectId, userId, 'furniture.requirement-created', { requirementId: saved.id, sourceRequirementId: id, name: saved.name, }); return saved; } async options(projectId: string, requirementId: string, userId: string) { await this.access.require(projectId, userId, 'read'); await this.owned(this.furniture.requirements, projectId, requirementId); return this.furniture.listOptions(projectId, requirementId); } async projectOptions( projectId: string, userId: string, query: FurnitureOptionListQueryDto, ) { await this.access.require(projectId, userId, 'read'); const allowed = new Set([ 'name', 'retailer', 'unitPrice', 'totalPrice', 'status', 'availability', 'expectedDeliveryDate', 'updatedAt', 'room', 'requirement', ]); if (!allowed.has(query.sortBy)) this.validation('Das Sortierfeld für Möbelalternativen ist ungültig.'); return this.furniture.pageOptions(projectId, query); } async option(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'read'); const option = await this.owned(this.furniture.options, projectId, id); const documentLinks = await this.furniture.optionDocuments.find({ where: { projectId, optionId: id }, }); const expenses = await this.renovation.expenses.find({ where: { projectId, furnitureOptionId: id }, }); return { ...option, documentIds: documentLinks.map((link) => link.documentId), expenses, }; } async createOption( projectId: string, requirementId: string, userId: string, dto: CreateFurnitureOptionDto, ) { await this.access.require(projectId, userId, 'edit'); await this.owned(this.furniture.requirements, projectId, requirementId); await this.validateOptionLinks(projectId, dto.budgetCategoryId); const totalPrice = this.total(dto); const saved = await this.furniture.options.save( this.furniture.options.create({ ...this.optionValues(dto), projectId, requirementId, totalPrice, currentlySelected: false, createdByUserId: userId, }), ); await this.furniture.requirements.update( { id: requirementId, projectId }, { status: FurnitureRequirementStatus.HasOptions }, ); await this.activity(projectId, userId, 'furniture.option-created', { requirementId, optionId: saved.id, name: saved.name, }); return saved; } async updateOption( projectId: string, id: string, userId: string, dto: UpdateFurnitureOptionDto, ) { await this.access.require(projectId, userId, 'edit'); const before = await this.owned(this.furniture.options, projectId, id); await this.validateOptionLinks(projectId, dto.budgetCategoryId); await this.versioned(this.furniture.options, projectId, id, dto.version, { ...this.optionValues(dto), totalPrice: this.total(dto), }); const saved = await this.owned(this.furniture.options, projectId, id); await this.activity( projectId, userId, before.totalPrice === saved.totalPrice ? 'furniture.option-updated' : 'furniture.price-changed', { optionId: id, oldPrice: before.totalPrice, newPrice: saved.totalPrice }, ); if (before.totalPrice !== saved.totalPrice && saved.favorite) await this.notifyResponsible( projectId, saved.requirementId, userId, 'Preis eines Favoriten geändert', `Der Preis von „${saved.name}“ wurde geändert.`, ); return saved; } async deleteOption(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); const option = await this.owned(this.furniture.options, projectId, id); if ( option.orderedAt || [FurnitureOptionStatus.Ordered, FurnitureOptionStatus.Delivered].includes( option.status, ) ) this.conflict( 'Bestellte oder gelieferte Produkte können nur archiviert werden.', ); await this.furniture.options.softDelete({ id, projectId }); await this.activity(projectId, userId, 'furniture.option-archived', { optionId: id, }); } async copyOption(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); const source = await this.owned(this.furniture.options, projectId, id); const saved = await this.furniture.options.save( this.furniture.options.create({ projectId, requirementId: source.requirementId, name: `${source.name} (Kopie)`, manufacturer: source.manufacturer, model: source.model, description: source.description, retailer: source.retailer, productUrl: source.productUrl, articleNumber: source.articleNumber, unitPrice: source.unitPrice, originalPrice: source.originalPrice, shippingCost: source.shippingCost, additionalCost: source.additionalCost, discount: source.discount, totalPrice: source.totalPrice, currency: source.currency, quantity: source.quantity, width: source.width, height: source.height, depth: source.depth, weight: source.weight, color: source.color, material: source.material, deliveryDays: source.deliveryDays, earliestDeliveryDate: source.earliestDeliveryDate, expectedDeliveryDate: source.expectedDeliveryDate, returnDeadline: source.returnDeadline, availability: source.availability, favorite: false, currentlySelected: false, status: FurnitureOptionStatus.Reviewing, notes: source.notes, budgetCategoryId: source.budgetCategoryId, existingItem: source.existingItem, estimatedCurrentValue: source.estimatedCurrentValue, movingCost: source.movingCost, refurbishmentCost: source.refurbishmentCost, currentLocation: source.currentLocation, condition: source.condition, orderedAt: null, orderedByUserId: null, orderNumber: null, actualDeliveryDate: null, deliveryStatus: FurnitureDeliveryStatus.NotOrdered, deliveredQuantity: 0, assemblyDate: null, assembledBy: null, createdByUserId: userId, }), ); await this.activity(projectId, userId, 'furniture.option-created', { optionId: saved.id, sourceOptionId: id, requirementId: saved.requirementId, }); return saved; } async setFavorite(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); const option = await this.owned(this.furniture.options, projectId, id); option.favorite = !option.favorite; option.status = option.favorite ? FurnitureOptionStatus.Favorite : FurnitureOptionStatus.Reviewing; const saved = await this.furniture.options.save(option); await this.activity(projectId, userId, 'furniture.favorite-changed', { optionId: id, favorite: saved.favorite, }); return saved; } async select(projectId: string, id: string, userId: string, version: number) { await this.access.require(projectId, userId, 'edit'); return this.dataSource.transaction(async (manager) => { const options = manager.getRepository(FurnitureOptionEntity); const candidate = await options.findOneBy({ id, projectId }); if (!candidate || candidate.deletedAt) this.notFound(); await manager .getRepository(FurnitureRequirementEntity) .createQueryBuilder('requirement') .setLock('pessimistic_write') .where( 'requirement.id = :requirementId AND requirement.projectId = :projectId', { requirementId: candidate.requirementId, projectId, }, ) .getOneOrFail(); const option = await options .createQueryBuilder('option') .setLock('pessimistic_write') .where( 'option.id = :id AND option.projectId = :projectId AND option.deletedAt IS NULL', { id, projectId }, ) .getOne(); if (!option) this.notFound(); if (option.version !== version) this.conflict( 'Die Alternative wurde zwischenzeitlich geändert. Laden Sie die Daten neu.', ); if ( [ FurnitureOptionStatus.Archived, FurnitureOptionStatus.Unavailable, FurnitureOptionStatus.Rejected, FurnitureOptionStatus.Returned, ].includes(option.status) || [ FurnitureAvailability.Unavailable, FurnitureAvailability.Discontinued, ].includes(option.availability) ) this.conflict('Diese Alternative ist nicht auswählbar.'); await options.update( { projectId, requirementId: option.requirementId, currentlySelected: true, }, { currentlySelected: false }, ); option.currentlySelected = true; option.status = FurnitureOptionStatus.Selected; const saved = await options.save(option); await manager .getRepository(FurnitureRequirementEntity) .update( { id: option.requirementId, projectId }, { status: FurnitureRequirementStatus.Selected }, ); await this.activity( projectId, userId, 'furniture.option-selected', { optionId: id, requirementId: option.requirementId }, manager, ); return saved; }); } async order( projectId: string, id: string, userId: string, dto: FurnitureOrderDto, ) { await this.access.require(projectId, userId, 'edit'); const option = await this.owned(this.furniture.options, projectId, id); await this.versioned(this.furniture.options, projectId, id, dto.version, { orderedAt: new Date(), orderedByUserId: userId, orderNumber: dto.orderNumber?.trim() || null, expectedDeliveryDate: dto.expectedDeliveryDate ?? option.expectedDeliveryDate, deliveryStatus: dto.deliveryStatus, status: FurnitureOptionStatus.Ordered, }); await this.furniture.requirements.update( { id: option.requirementId, projectId }, { status: FurnitureRequirementStatus.Ordered }, ); await this.activity(projectId, userId, 'furniture.ordered', { optionId: id, requirementId: option.requirementId, }); await this.notifyResponsible( projectId, option.requirementId, userId, 'Möbel bestellt', `„${option.name}“ wurde bestellt.`, ); return this.owned(this.furniture.options, projectId, id); } async deliver( projectId: string, id: string, userId: string, dto: FurnitureDeliveryDto, ) { await this.access.require(projectId, userId, 'edit'); const option = await this.owned(this.furniture.options, projectId, id); if (dto.deliveredQuantity > option.quantity) this.validation( 'Die gelieferte Menge darf die bestellte Menge nicht überschreiten.', ); const complete = dto.deliveredQuantity === option.quantity; await this.versioned(this.furniture.options, projectId, id, dto.version, { deliveredQuantity: dto.deliveredQuantity, actualDeliveryDate: dto.actualDeliveryDate ?? new Date().toISOString().slice(0, 10), deliveryStatus: complete ? FurnitureDeliveryStatus.Delivered : FurnitureDeliveryStatus.PartiallyDelivered, status: complete ? FurnitureOptionStatus.Delivered : FurnitureOptionStatus.Ordered, }); await this.furniture.requirements.update( { id: option.requirementId, projectId }, { status: complete ? FurnitureRequirementStatus.Delivered : FurnitureRequirementStatus.PartiallyDelivered, }, ); await this.activity(projectId, userId, 'furniture.delivered', { optionId: id, deliveredQuantity: dto.deliveredQuantity, }); return this.owned(this.furniture.options, projectId, id); } async compareOptions( projectId: string, requirementId: string, userId: string, ) { const requirement = await this.requirement( projectId, requirementId, userId, ); const active = requirement.options.filter( (option) => option.status !== FurnitureOptionStatus.Archived, ); const minimum = active.reduce( (min, option) => min === null || Number(option.totalPrice) < Number(min) ? option.totalPrice : min, null, ); return { requirement: { ...requirement, options: undefined }, options: active.map((option) => ({ ...option, cheapest: option.totalPrice === minimum, delayed: this.delayed(option), })), }; } async linkDocument( projectId: string, optionId: string, userId: string, dto: FurnitureDocumentLinkDto, ) { await this.access.require(projectId, userId, 'edit'); await this.owned(this.furniture.options, projectId, optionId); await this.owned(this.renovation.documents, projectId, dto.documentId); const existing = await this.furniture.optionDocuments.findOneBy({ optionId, documentId: dto.documentId, }); if (!existing) await this.furniture.optionDocuments.save( this.furniture.optionDocuments.create({ projectId, optionId, documentId: dto.documentId, }), ); return { linked: true }; } async scenarios(projectId: string, userId: string) { await this.access.require(projectId, userId, 'read'); const scenarios = await this.furniture.listScenarios(projectId); return Promise.all( scenarios.map((scenario) => this.scenarioResult(scenario)), ); } async scenario(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'read'); return this.scenarioResult( await this.owned(this.furniture.scenarios, projectId, id), ); } async createScenario( projectId: string, userId: string, dto: CreateFurnitureScenarioDto, ) { await this.access.require(projectId, userId, 'edit'); return this.dataSource.transaction(async (manager) => { const scenarios = manager.getRepository(FurnitureScenarioEntity); if (dto.isDefault) await scenarios.update( { projectId, isDefault: true }, { isDefault: false }, ); const saved = await scenarios.save( scenarios.create({ projectId, name: dto.name.trim(), description: dto.description?.trim() || null, type: dto.type, status: dto.status, isDefault: dto.isDefault, createdByUserId: userId, }), ); if (dto.copyFromScenarioId) await this.copySelections( projectId, dto.copyFromScenarioId, saved.id, manager, ); else if ( dto.automaticSelection && dto.automaticSelection !== FurnitureScenarioType.Custom ) await this.autoSelections( projectId, saved.id, dto.automaticSelection, manager, ); await this.activity( projectId, userId, dto.copyFromScenarioId ? 'furniture.scenario-copied' : 'furniture.scenario-created', { scenarioId: saved.id, name: saved.name }, manager, ); return this.scenarioResult(saved, manager); }); } async updateScenario( projectId: string, id: string, userId: string, dto: UpdateFurnitureScenarioDto, ) { await this.access.require(projectId, userId, 'edit'); await this.owned(this.furniture.scenarios, projectId, id); if (dto.isDefault) await this.furniture.scenarios.update( { projectId, isDefault: true }, { isDefault: false }, ); await this.versioned(this.furniture.scenarios, projectId, id, dto.version, { name: dto.name.trim(), description: dto.description?.trim() || null, type: dto.type, status: dto.status, isDefault: dto.isDefault, }); await this.activity(projectId, userId, 'furniture.scenario-updated', { scenarioId: id, }); return this.scenario(projectId, id, userId); } async deleteScenario(projectId: string, id: string, userId: string) { await this.access.require(projectId, userId, 'edit'); const scenario = await this.owned(this.furniture.scenarios, projectId, id); if (scenario.isDefault) this.conflict( 'Das Standardszenario kann nicht archiviert werden. Legen Sie zuerst ein anderes Standardszenario fest.', ); await this.furniture.scenarios.softDelete({ id, projectId }); } async updateSelections( projectId: string, scenarioId: string, userId: string, dto: UpdateFurnitureScenarioSelectionsDto, ) { await this.access.require(projectId, userId, 'edit'); return this.dataSource.transaction(async (manager) => { const scenarios = manager.getRepository(FurnitureScenarioEntity); const scenario = await scenarios .createQueryBuilder('scenario') .setLock('pessimistic_write') .where( 'scenario.id = :scenarioId AND scenario.projectId = :projectId AND scenario.deletedAt IS NULL', { scenarioId, projectId }, ) .getOne(); if (!scenario) this.notFound(); if (scenario.version !== dto.version) this.conflict( 'Das Szenario wurde zwischenzeitlich geändert. Laden Sie es neu.', ); if (scenario.status === FurnitureScenarioStatus.Archived) this.conflict('Ein archiviertes Szenario kann nicht geändert werden.'); const selections = manager.getRepository( FurnitureScenarioSelectionEntity, ); const replacements: FurnitureScenarioSelectionEntity[] = []; for (const selection of dto.selections) { const requirement = await manager .getRepository(FurnitureRequirementEntity) .findOneBy({ id: selection.requirementId, projectId }); const option = await manager .getRepository(FurnitureOptionEntity) .findOneBy({ id: selection.optionId, projectId, requirementId: selection.requirementId, }); if ( !requirement || !option || option.deletedAt || [ FurnitureOptionStatus.Archived, FurnitureOptionStatus.Unavailable, ].includes(option.status) ) this.validation( 'Eine Szenarioauswahl ist ungültig oder gehört nicht zum Möbelbedarf.', ); replacements.push( selections.create({ projectId, scenarioId, requirementId: selection.requirementId, optionId: selection.optionId, quantity: selection.quantity, priceOverride: this.decimal(selection.priceOverride), note: selection.note?.trim() || null, }), ); } await selections.delete({ projectId, scenarioId }); await selections.save(replacements); scenario.version += 1; await scenarios.save(scenario); await this.activity( projectId, userId, 'furniture.scenario-selection-changed', { scenarioId, selectionCount: replacements.length }, manager, ); return this.scenarioResult(scenario, manager); }); } async compareScenarios(projectId: string, userId: string, ids?: string[]) { await this.access.require(projectId, userId, 'read'); const all = await this.furniture.listScenarios(projectId); const selected = ids?.length ? all.filter((scenario) => ids.includes(scenario.id)) : all; const results = await Promise.all( selected.map((scenario) => this.scenarioResult(scenario)), ); const rooms = await this.renovation.listRooms(projectId); return { scenarios: results, rooms: rooms.map((room) => ({ id: room.id, name: room.name, costs: Object.fromEntries( results.map((result) => [ result.id, result.byRoom[room.id] ?? '0.00', ]), ), })), costDrivers: this.costDrivers(results), }; } async summary(projectId: string, userId: string, roomId?: string) { await this.access.require(projectId, userId, 'read'); if (roomId) await this.owned(this.renovation.rooms, projectId, roomId); const requirements = await this.furniture.listRequirements( projectId, roomId, ); const options = requirements.length ? await this.furniture.options.find({ where: requirements.map((requirement) => ({ projectId, requirementId: requirement.id, deletedAt: IsNull(), })), }) : []; const selected = options.filter((option) => option.currentlySelected); const favorites = requirements .map( (requirement) => options.find( (option) => option.requirementId === requirement.id && option.favorite, ) ?? options.find( (option) => option.requirementId === requirement.id && option.currentlySelected, ), ) .filter((option): option is FurnitureOptionEntity => !!option); const cheapest = requirements .map( (requirement) => options .filter( (option) => option.requirementId === requirement.id && this.available(option), ) .sort((a, b) => Number(a.totalPrice) - Number(b.totalPrice))[0], ) .filter((option): option is FurnitureOptionEntity => !!option); const expenses = selected.length ? await this.renovation.expenses.find({ where: selected.map((option) => ({ projectId, furnitureOptionId: option.id, })), }) : []; const scenarios = await this.scenarios(projectId, userId); const today = new Date().toISOString().slice(0, 10); return { requirements: requirements.length, withoutOption: requirements.filter( (requirement) => !options.some((option) => option.requirementId === requirement.id), ).length, withoutDecision: requirements.filter( (requirement) => !selected.some((option) => option.requirementId === requirement.id), ).length, selected: selected.length, ordered: options.filter((option) => !!option.orderedAt).length, delivered: options.filter( (option) => option.deliveryStatus === FurnitureDeliveryStatus.Delivered, ).length, delayed: options.filter( (option) => !!option.expectedDeliveryDate && option.expectedDeliveryDate < today && ![ FurnitureDeliveryStatus.Delivered, FurnitureDeliveryStatus.Cancelled, FurnitureDeliveryStatus.Returned, ].includes(option.deliveryStatus), ).length, budget: sumMoney( requirements.map((requirement) => requirement.maximumBudget ?? '0'), ), cheapestCost: sumMoney(cheapest.map((option) => option.totalPrice)), favoriteCost: sumMoney(favorites.map((option) => option.totalPrice)), selectedCost: sumMoney(selected.map((option) => option.totalPrice)), actualExpenseCost: sumMoney( expenses .filter((expense) => expense.paymentStatus !== 'cancelled') .map((expense) => expense.amount), ), openPrices: requirements.filter( (requirement) => !options.some((option) => option.requirementId === requirement.id), ).length, overBudget: requirements.filter((requirement) => { const option = selected.find( (candidate) => candidate.requirementId === requirement.id, ); return ( !!option && !!requirement.maximumBudget && Number(option.totalPrice) > Number(requirement.maximumBudget) ); }).length, scenarios: scenarios.map((scenario) => ({ id: scenario.id, name: scenario.name, total: scenario.total, })), }; } async createExpense( projectId: string, optionId: string, userId: string, dto: FurnitureExpenseDto, ) { await this.access.require(projectId, userId, 'edit'); const option = await this.owned( this.furniture.options, projectId, optionId, ); const requirement = await this.owned( this.furniture.requirements, projectId, option.requirementId, ); await this.owned(this.renovation.budgets, projectId, dto.budgetCategoryId); const expense = await this.renovation.expenses.save( this.renovation.expenses.create({ projectId, budgetCategoryId: dto.budgetCategoryId, roomId: requirement.roomId, taskId: null, furnitureRequirementId: requirement.id, furnitureOptionId: option.id, title: dto.title.trim(), description: null, amount: this.decimal(dto.amount) ?? option.totalPrice, currency: option.currency, expenseDate: new Date().toISOString().slice(0, 10), paymentStatus: dto.paymentStatus, dueDate: dto.dueDate ?? null, supplier: dto.supplier?.trim() || option.retailer, invoiceNumber: null, documentId: null, createdByUserId: userId, }), ); await this.activity(projectId, userId, 'furniture.expense-created', { optionId, expenseId: expense.id, amount: expense.amount, }); return expense; } private async scenarioResult( scenario: FurnitureScenarioEntity, manager?: EntityManager, ) { const selections = await ( manager?.getRepository(FurnitureScenarioSelectionEntity) ?? this.furniture.selections ).find({ where: { projectId: scenario.projectId, scenarioId: scenario.id }, }); const options = selections.length ? await ( manager?.getRepository(FurnitureOptionEntity) ?? this.furniture.options ).find({ where: selections.map((selection) => ({ id: selection.optionId, projectId: scenario.projectId, })), }) : []; const requirements = selections.length ? await ( manager?.getRepository(FurnitureRequirementEntity) ?? this.furniture.requirements ).find({ where: selections.map((selection) => ({ id: selection.requirementId, projectId: scenario.projectId, })), }) : []; const totalFor = (selection: FurnitureScenarioSelectionEntity) => selection.priceOverride ?? options.find((option) => option.id === selection.optionId)?.totalPrice ?? '0'; const byRoom: Record = {}; for (const selection of selections) { const requirement = requirements.find( (entry) => entry.id === selection.requirementId, ); if (requirement?.roomId) byRoom[requirement.roomId] = sumMoney([ byRoom[requirement.roomId] ?? '0', totalFor(selection), ]); } const totalRequirements = await ( manager?.getRepository(FurnitureRequirementEntity) ?? this.furniture.requirements ).count({ where: { projectId: scenario.projectId, deletedAt: IsNull() } }); return { ...scenario, selections, total: sumMoney(selections.map(totalFor)), byRoom, selectedRequirements: selections.length, openRequirements: Math.max(0, totalRequirements - selections.length), shippingCost: sumMoney(options.map((option) => option.shippingCost)), additionalCost: sumMoney(options.map((option) => option.additionalCost)), existingItems: options.filter((option) => option.existingItem).length, }; } private async copySelections( projectId: string, sourceId: string, targetId: string, manager: EntityManager, ) { const source = await manager .getRepository(FurnitureScenarioEntity) .findOneBy({ id: sourceId, projectId }); if (!source) this.notFound(); const repository = manager.getRepository(FurnitureScenarioSelectionEntity); const entries = await repository.find({ where: { projectId, scenarioId: sourceId }, }); await repository.save( entries.map((entry) => repository.create({ projectId: entry.projectId, scenarioId: targetId, requirementId: entry.requirementId, optionId: entry.optionId, quantity: entry.quantity, priceOverride: entry.priceOverride, note: entry.note, }), ), ); } private async autoSelections( projectId: string, scenarioId: string, type: FurnitureScenarioType, manager: EntityManager, ) { const requirements = await manager .getRepository(FurnitureRequirementEntity) .find({ where: { projectId, deletedAt: IsNull() } }); const optionRepo = manager.getRepository(FurnitureOptionEntity); const selectionRepo = manager.getRepository( FurnitureScenarioSelectionEntity, ); const selections: FurnitureScenarioSelectionEntity[] = []; for (const requirement of requirements) { let options = ( await optionRepo.find({ where: { projectId, requirementId: requirement.id, deletedAt: IsNull(), }, }) ).filter((option) => this.available(option)); options = options.sort( (a, b) => Number(a.totalPrice) - Number(b.totalPrice), ); const option = type === FurnitureScenarioType.Premium ? options.at(-1) : type === FurnitureScenarioType.Preferred ? (options.find((entry) => entry.currentlySelected) ?? options.find((entry) => entry.favorite) ?? options[0]) : type === FurnitureScenarioType.Existing ? options.find((entry) => entry.existingItem) : options[0]; if (option) selections.push( selectionRepo.create({ projectId, scenarioId, requirementId: requirement.id, optionId: option.id, quantity: option.quantity, priceOverride: null, note: null, }), ); } await selectionRepo.save(selections); } private optionValues(dto: CreateFurnitureOptionDto) { const text = (value?: string) => value?.trim() || null; const decimal = (value?: number | null) => value === undefined || value === null ? null : value.toFixed(2); return { name: dto.name?.trim() || 'Unbenannter Möbelvorschlag', manufacturer: text(dto.manufacturer), model: text(dto.model), description: text(dto.description), retailer: text(dto.retailer), productUrl: text(dto.productUrl), articleNumber: text(dto.articleNumber), unitPrice: (dto.unitPrice ?? 0).toFixed(2), originalPrice: decimal(dto.originalPrice), shippingCost: (dto.shippingCost ?? 0).toFixed(2), additionalCost: (dto.additionalCost ?? 0).toFixed(2), discount: (dto.discount ?? 0).toFixed(2), currency: dto.currency.toUpperCase(), quantity: dto.quantity ?? 1, width: decimal(dto.width), height: decimal(dto.height), depth: decimal(dto.depth), weight: decimal(dto.weight), color: text(dto.color), material: text(dto.material), deliveryDays: dto.deliveryDays ?? null, earliestDeliveryDate: dto.earliestDeliveryDate ?? null, expectedDeliveryDate: dto.expectedDeliveryDate ?? null, returnDeadline: dto.returnDeadline ?? null, availability: dto.availability, favorite: dto.favorite, status: dto.status, notes: text(dto.notes), budgetCategoryId: dto.budgetCategoryId ?? null, existingItem: dto.existingItem, estimatedCurrentValue: decimal(dto.estimatedCurrentValue), movingCost: (dto.movingCost ?? 0).toFixed(2), refurbishmentCost: (dto.refurbishmentCost ?? 0).toFixed(2), currentLocation: text(dto.currentLocation), condition: dto.condition ?? null, }; } private total(dto: CreateFurnitureOptionDto) { try { return calculateFurnitureTotal({ ...dto, unitPrice: dto.unitPrice ?? 0, quantity: dto.quantity ?? 1, }); } catch { this.validation( 'Der Rabatt darf die berechneten Gesamtkosten nicht überschreiten.', ); } } private available(option: FurnitureOptionEntity) { return ( ![ FurnitureOptionStatus.Archived, FurnitureOptionStatus.Rejected, FurnitureOptionStatus.Unavailable, FurnitureOptionStatus.Returned, ].includes(option.status) && ![ FurnitureAvailability.Unavailable, FurnitureAvailability.Discontinued, ].includes(option.availability) ); } private delayed(option: FurnitureOptionEntity) { return ( !!option.expectedDeliveryDate && option.expectedDeliveryDate < new Date().toISOString().slice(0, 10) && ![ FurnitureDeliveryStatus.Delivered, FurnitureDeliveryStatus.Cancelled, FurnitureDeliveryStatus.Returned, ].includes(option.deliveryStatus) ); } private costDrivers( results: Array<{ name: string; selections: FurnitureScenarioSelectionEntity[]; total: string; }>, ) { if (results.length < 2) return []; const base = results[0]; const comparison = results[1]; if (!base || !comparison) return []; const ids = new Set([ ...base.selections.map((s) => s.requirementId), ...comparison.selections.map((s) => s.requirementId), ]); return [...ids] .map((requirementId) => ({ requirementId, changed: base.selections.find((s) => s.requirementId === requirementId) ?.optionId !== comparison.selections.find((s) => s.requirementId === requirementId) ?.optionId, })) .filter((entry) => entry.changed); } private async validateRequirementLinks( projectId: string, roomId?: string, responsibleUserId?: string, ) { if (roomId) await this.owned(this.renovation.rooms, projectId, roomId); if (responsibleUserId) { const member = await this.projects.findMembership( projectId, responsibleUserId, ); if (!member?.active || !member.user.active) this.validation( 'Der Verantwortliche ist kein aktives Projektmitglied.', ); } } private async validateOptionLinks( projectId: string, budgetCategoryId?: string, ) { if (budgetCategoryId) await this.owned(this.renovation.budgets, projectId, budgetCategoryId); } private async notifyResponsible( projectId: string, requirementId: string, actorId: string, title: string, message: string, ) { const requirement = await this.owned( this.furniture.requirements, projectId, requirementId, ); if ( requirement.responsibleUserId && requirement.responsibleUserId !== actorId ) await this.notifications.createForUser({ userId: requirement.responsibleUserId, type: 'furniture.status-changed', title, message, link: `/projekte/${projectId}/moebel?requirement=${requirementId}`, metadata: { projectId, requirementId }, }); } private async activity( projectId: string, userId: string, action: string, metadata: Record, manager?: EntityManager, ) { const entity = new ProjectActivityEntity(); entity.projectId = projectId; entity.actorUserId = userId; entity.action = action; entity.metadata = metadata; await this.projects.saveActivity(entity, manager); } private async owned( repository: { findOneBy(where: object): Promise }, projectId: string, id: string, ): Promise { const entity = await repository.findOneBy({ id, projectId }); if (!entity) this.notFound(); return entity; } private async versioned( repository: { update( criteria: object, values: object, ): Promise<{ affected?: number | null }>; }, projectId: string, id: string, version: number, values: object, ) { const result = await repository.update( { id, projectId, version }, { ...values, version: () => 'version + 1' }, ); if (result.affected !== 1) this.conflict( 'Dieser Datensatz wurde zwischenzeitlich geändert. Laden Sie die aktuellen Daten neu.', ); } private decimal(value?: number | null) { return formatOptionalMoney(value); } private validation(message: string): never { throw new ApiError(ErrorCode.ValidationFailed, message, 400); } private notFound(): never { throw new ApiError( ErrorCode.NotFound, 'Der Datensatz wurde nicht gefunden.', 404, ); } private conflict(message: string): never { throw new ApiError(ErrorCode.Conflict, message, 409); } }