generated from bastian/boilerplate
1341 lines
44 KiB
TypeScript
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);
|
|
}
|
|
}
|