Files
flat-pilot/apps/backend/src/renovation/furniture.service.ts
Bastian Wagner 4214c2d436 szenario
2026-07-20 21:36:54 +02:00

1341 lines
44 KiB
TypeScript

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<string | null>(
(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<string, string> = {};
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<string, string | number | boolean | null>,
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<T extends { id: string; projectId: string }>(
repository: { findOneBy(where: object): Promise<T | null> },
projectId: string,
id: string,
): Promise<T> {
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);
}
}