diff --git a/openspec/changes/configurable-leistungsnachweis-generation/.openspec.yaml b/openspec/changes/configurable-leistungsnachweis-generation/.openspec.yaml new file mode 100644 index 000000000..43e65ca6e --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/configurable-leistungsnachweis-generation/design.md b/openspec/changes/configurable-leistungsnachweis-generation/design.md new file mode 100644 index 000000000..47a85e6ac --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/design.md @@ -0,0 +1,65 @@ +## Context + +`LEISTUNGSNACHWEIS` (proof-of-performance) month-end tasks are generated in `MonthEndTaskPlanningService.planProjectTasks(...)`. Today the sole gate is `project.billable() && !activeLeadIds.isEmpty()`. Project leads cannot suppress the task for projects where the workflow does not apply. + +The `Project` aggregate lives in the `project` bounded context and is largely ZEP-synced (`name`, dates, `billable`), with `leads` being the one MEGA-managed field. Month-end generation reads projects through the anti-corruption boundary `MonthEndProjectSnapshot`, produced by `ProjectSnapshotAdapter`/`MonthEndProjectSnapshotMapper`. The `project` BC currently exposes **no inbound adapter** — projects only leave the BC as internal snapshots for month-end. + +There is no existing frontend path to read a lead's projects, so the toggle needs both a read and a write endpoint to be usable. + +## Goals / Non-Goals + +**Goals:** +- Let a project lead opt a specific project out of (and back into) `LEISTUNGSNACHWEIS` generation. +- Preserve current behaviour for every existing project (default enabled). +- Keep the flag owned by the `project` BC and carried to month-end via the existing snapshot boundary. +- Provide the read + write REST surface the frontend needs. + +**Non-Goals:** +- Making other task types (`ABRECHNUNG`, `PROJECT_LEAD_REVIEW`, `EMPLOYEE_TIME_CHECK`) configurable. +- Retroactively adding or removing already-generated tasks when the flag changes. +- Any generic "task-type configuration" framework. +- Sourcing the flag from ZEP. + +## Decisions + +### The flag lives on the `Project` aggregate, defaulting to `true` +`leistungsnachweisEnabled` is a MEGA-managed boolean on `Project`, alongside `leads`. It defaults to `true` so opt-out semantics preserve today's behaviour. `Project.create(...)` sets it `true`; `withSyncedZepData(...)` and `withLeads(...)` preserve it; a new `withLeistungsnachweisEnabled(boolean)` returns a new instance with the flag changed. + +*Alternative considered:* a monthend-owned configuration keyed by project id. Rejected — `billable` already lives on `Project` and drives the same task, so co-locating keeps the gate's inputs in one aggregate and avoids a second source of truth. + +### Generation reads the flag at generation time (no retroactivity) +`MonthEndProjectSnapshot` gains `leistungsnachweisEnabled`; `MonthEndTaskPlanningService` changes the gate to `project.billable() && !activeLeadIds.isEmpty() && project.leistungsnachweisEnabled()`. Because the value is evaluated only during a generation run, toggling affects the next run only. Idempotent regeneration is unchanged: turning the flag off does not delete existing tasks, and turning it on adds the task on the next run. + +*Alternative considered:* reconcile open `LEISTUNGSNACHWEIS` tasks immediately on toggle. Rejected as out of scope and more complex; batch-at-generation matches the existing generation model. + +### First inbound adapter on the `project` BC; two-layer authorization +A new `ProjectResource` implements the generated `ProjectApi`: +- `GET /projects` → `GetLeadProjectsUseCase`, returns the caller's led projects (via the existing `ProjectRepository.findAllByLead`). The `PROJECT_LEAD` role gate alone is sufficient because results are already scoped to the caller. +- `PUT /projects/{projectId}/leistungsnachweis-enabled` → `SetLeistungsnachweisEnabledUseCase`, which loads the project, asserts `project.leads().contains(actorId)` (throwing an authorization error otherwise), applies `withLeistungsnachweisEnabled(enabled)`, and saves. The `PROJECT_LEAD` role gate is not enough here — a lead of project A must not toggle project B — so the per-project lead check is enforced in the application service. + +This follows the established pattern (`@MegaRolesAllowed`, `AuthenticatedActorContext.userId()`) and the codebase convention that cross-project authorization is a domain/application concern, not just a role annotation. + +### Persistence +`ProjectEntity` gains a `leistungsnachweis_enabled` boolean column (`nullable = false`). A Liquibase changelog adds it with `defaultValueBoolean: true` so existing rows backfill to enabled. The entity↔domain MapStruct mapper carries the field both ways. + +### DTO shape +`GET /projects` returns items of `{ id, zepId, name, billable, leistungsnachweisEnabled }`. `billable` is included so the UI can convey that the toggle has no effect on non-billable projects. `PUT` takes `{ enabled: boolean }`. + +## Risks / Trade-offs + +- **Default `false` would silently disable every billable project's proof-of-performance at the next month-end.** → Column defaults to `true` (opt-out) and the Liquibase change backfills existing rows to `true`. +- **A lead toggling another project's flag.** → Per-project `leads().contains(actorId)` check in the application service, not just the role gate. +- **Toggling mid-month is invisible until the next generation run** (could confuse users expecting immediate effect). → Accepted and documented as intended non-retroactive behaviour; the frontend can message this. +- **Introducing the first inbound adapter on the `project` BC** adds REST/OpenAPI surface where there was none. → Kept minimal (two endpoints) and consistent with existing resource conventions. + +## Migration Plan + +1. Add the Liquibase changelog (`leistungsnachweis_enabled` column, default `true`, not null) — backfills existing projects to enabled. +2. Deploy backend (domain gate, snapshot, endpoints). Behaviour is unchanged until a lead actively disables a project. +3. Frontend ships the configuration page consuming the new endpoints. + +Rollback: the flag defaults to enabled, so reverting the code leaves generation behaving exactly as before; the extra column is inert if unused. + +## Open Questions + +None outstanding — default (opt-out), non-retroactivity, single-boolean scope, aggregate ownership, and the read+write surface were all settled during exploration. diff --git a/openspec/changes/configurable-leistungsnachweis-generation/proposal.md b/openspec/changes/configurable-leistungsnachweis-generation/proposal.md new file mode 100644 index 000000000..135002717 --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/proposal.md @@ -0,0 +1,32 @@ +## Why + +Today a `LEISTUNGSNACHWEIS` month-end task is generated for every billable project that has active leads — project leads have no way to suppress it for projects where the proof-of-performance workflow does not apply. Project leads need a per-project switch to opt out of `LEISTUNGSNACHWEIS` generation. + +## What Changes + +- The `Project` aggregate gains a `leistungsnachweisEnabled` boolean, **defaulting to `true`** (opt-out semantics — existing behaviour is preserved for all current projects). +- ZEP resync preserves the flag (it is MEGA-managed, like project leads); it is never overwritten from ZEP. +- `MonthEndProjectSnapshot` carries the flag into the month-end context. +- Month-end task generation gates `LEISTUNGSNACHWEIS` on the flag: a task is created only when the project is billable, has active leads, **and** `leistungsnachweisEnabled` is `true`. The flag is read at generation time, so a toggle change only affects the next generation run (no retroactive add/removal of already-generated tasks). +- A new inbound REST surface on the `project` bounded context (its first inbound adapter): + - `GET /projects` — returns the authenticated project lead's own projects with their `leistungsnachweisEnabled` state, so the frontend can render an editable toggle page. + - `PUT /projects/{projectId}/leistungsnachweis-enabled` — sets the flag. Authorized by the `PROJECT_LEAD` role **and** a per-project check that the caller is a lead of that specific project. + +## Capabilities + +### New Capabilities +- `project-rest-api`: inbound REST endpoints on the project bounded context for a project lead to read their led projects and toggle per-project `LEISTUNGSNACHWEIS` generation, with role-based and per-project lead authorization. + +### Modified Capabilities +- `project-aggregate`: the `Project` aggregate and its `MonthEndProjectSnapshot` read model gain a `leistungsnachweisEnabled` flag (default `true`), preserved across ZEP resync and lead sync. +- `monthend-task-generation`: `LEISTUNGSNACHWEIS` generation is additionally gated on the project's `leistungsnachweisEnabled` flag. + +## Impact + +- `Project` (project domain aggregate) and `ZepProjectProfile` interaction (flag preserved, not sourced from ZEP) +- `MonthEndProjectSnapshot` (month-end read model) + its mapper +- `MonthEndTaskPlanningService` (generation gating logic) +- `ProjectEntity` + new Liquibase changelog (new `leistungsnachweis_enabled` column, default `true`) +- New `ProjectResource` inbound adapter, `GetLeadProjectsUseCase` / `SetLeistungsnachweisEnabledUseCase` and application services in the project BC +- OpenAPI schema (new project read DTO + toggle request DTO, generated `ProjectApi`) +- Frontend: new project-lead configuration page consuming the two endpoints diff --git a/openspec/changes/configurable-leistungsnachweis-generation/specs/monthend-task-generation/spec.md b/openspec/changes/configurable-leistungsnachweis-generation/specs/monthend-task-generation/spec.md new file mode 100644 index 000000000..31d27490b --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/specs/monthend-task-generation/spec.md @@ -0,0 +1,36 @@ +## MODIFIED Requirements + +### Requirement: Generation creates project-owned month-end tasks with eligible leads +The system SHALL generate project-owned month-end tasks for active projects using the leads assigned at generation time. It MUST create one `PROJECT_LEAD_REVIEW` task per active assigned employee, one `LEISTUNGSNACHWEIS` task per active assigned employee on a billable project **whose `leistungsnachweisEnabled` flag is true**, and one `ABRECHNUNG` task per billable project, each with the eligible project leads captured at generation time. + +The `leistungsnachweisEnabled` flag SHALL be read from the project snapshot at generation time. A change to the flag SHALL only affect subsequent generation runs; it SHALL NOT retroactively add or remove already-generated `LEISTUNGSNACHWEIS` tasks. The flag SHALL gate `LEISTUNGSNACHWEIS` only; `PROJECT_LEAD_REVIEW` and `ABRECHNUNG` generation SHALL be unaffected by it. + +#### Scenario: Lead review is generated once per employee +- **WHEN** an active project has active assigned employees and at least one active lead +- **THEN** the system creates one `PROJECT_LEAD_REVIEW` task per active assigned employee with all active project leads as eligible actors + +#### Scenario: Leistungsnachweis is generated once per employee on an enabled billable project +- **WHEN** an active billable project has active assigned employees, at least one active lead, and `leistungsnachweisEnabled` is true +- **THEN** the system creates one `LEISTUNGSNACHWEIS` task per active assigned employee with all active project leads as eligible actors + +#### Scenario: Leistungsnachweis is not generated when the project flag is disabled +- **WHEN** an active billable project has active assigned employees and at least one active lead but `leistungsnachweisEnabled` is false +- **THEN** the system does not create `LEISTUNGSNACHWEIS` tasks for that project +- **THEN** `PROJECT_LEAD_REVIEW` and `ABRECHNUNG` tasks are still generated for that project + +#### Scenario: Leistungsnachweis is not generated when billable project has no active leads +- **WHEN** an active billable project has active assigned employees but no active leads +- **THEN** the system does not create `LEISTUNGSNACHWEIS` tasks for that project + +#### Scenario: Non-billable project does not generate Leistungsnachweis +- **WHEN** an active employee is assigned to a non-billable active project during month-end generation +- **THEN** the system does not create a `LEISTUNGSNACHWEIS` task for that assignment + +#### Scenario: Disabling the flag does not remove existing tasks +- **WHEN** a `LEISTUNGSNACHWEIS` task already exists for a project month and the project's `leistungsnachweisEnabled` flag is set to false afterwards +- **THEN** the existing `LEISTUNGSNACHWEIS` task is not removed +- **THEN** the next generation run for that month does not create additional `LEISTUNGSNACHWEIS` tasks for that project + +#### Scenario: Abrechnung is generated once per billable project +- **WHEN** an active billable project has at least one active lead +- **THEN** the system creates one `ABRECHNUNG` task for the project with all active project leads as eligible actors diff --git a/openspec/changes/configurable-leistungsnachweis-generation/specs/project-aggregate/spec.md b/openspec/changes/configurable-leistungsnachweis-generation/specs/project-aggregate/spec.md new file mode 100644 index 000000000..99309c568 --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/specs/project-aggregate/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Project aggregate encapsulates identity and master data +The `Project` aggregate SHALL be modeled as an immutable, record-oriented type holding a stable internal `ProjectId` (UUID), a ZEP numeric id (`zepId`), a unique `name`, a `startDate`, an optional `endDate`, a `billable` boolean flag, a `leistungsnachweisEnabled` boolean flag, and a set of `UserId` references representing project leads. State transitions such as ZEP resync and project lead sync SHALL return new Project instances instead of mutating existing state. The aggregate SHALL NOT hold any workflow state (that is the concern of a future capability). + +The `leistungsnachweisEnabled` flag is MEGA-managed (not sourced from ZEP) and SHALL default to `true` on project creation. It SHALL be preserved across ZEP resync and project lead sync. A dedicated state transition SHALL return a new Project instance with the flag set to a caller-provided value. + +`MonthEndProjectSnapshot` — the monthend-specific read model derived from `Project` — SHALL carry `{ ProjectId id, int zepId, String name, boolean billable, boolean leistungsnachweisEnabled, Set leadIds }` only. It SHALL NOT carry `startDate` or `endDate`; activeness filtering for a given month SHALL be enforced in the adapter before returning the snapshot to the application layer. + +#### Scenario: Project created from ZEP profile data +- **WHEN** `Project.create(ProjectId, ZepProjectProfile)` is called +- **THEN** a new immutable Project instance is returned with name, zepId, startDate, endDate, and billable populated from the profile +- **THEN** the leads set is empty +- **THEN** `leistungsnachweisEnabled` is `true` + +#### Scenario: Project reconstituted from persisted state +- **WHEN** `new Project(id, zepId, name, startDate, endDate, billable, leistungsnachweisEnabled, leads)` is called +- **THEN** a new immutable Project instance is returned with all fields set as provided + +#### Scenario: Existing project resynced from ZEP +- **WHEN** `Project.withSyncedZepData(ZepProjectProfile)` is called with updated profile data +- **THEN** a new Project instance is returned with updated name, startDate, endDate, and billable fields +- **THEN** the existing `ProjectId`, leads set, and `leistungsnachweisEnabled` flag are preserved + +#### Scenario: Leistungsnachweis flag is toggled +- **WHEN** `Project.withLeistungsnachweisEnabled(false)` is called on a project whose flag is `true` +- **THEN** a new Project instance is returned with `leistungsnachweisEnabled` set to `false` +- **THEN** all other fields, including `ProjectId` and leads, are preserved + +#### Scenario: MonthEndProjectSnapshot does not carry date range fields +- **WHEN** `MonthEndProjectSnapshot` is constructed +- **THEN** it contains `id`, `zepId`, `name`, `billable`, `leistungsnachweisEnabled`, and `leadIds` only +- **THEN** no `startDate` or `endDate` field exists on `MonthEndProjectSnapshot` + +### Requirement: Project leads are a set of UserId references +The `leads` field SHALL be a `Set` carried directly on the `Project` aggregate. The `Project` aggregate SHALL NOT hold raw UUID collections or references to full `User` objects. Lead replacement during project lead sync SHALL return a new Project instance with the resolved `UserId` values. Lead replacement SHALL preserve the `leistungsnachweisEnabled` flag. + +#### Scenario: Project has no leads after initial sync +- **WHEN** a project is first created via `SyncProjectsUseCase` +- **THEN** its leads set is empty + +#### Scenario: Leads are set by project lead sync +- **WHEN** `SyncProjectLeadsUseCase` resolves lead usernames for a project +- **THEN** the project's leads set is replaced with the resolved `UserId` values on a new Project instance +- **THEN** the `leistungsnachweisEnabled` flag is preserved on the new instance diff --git a/openspec/changes/configurable-leistungsnachweis-generation/specs/project-rest-api/spec.md b/openspec/changes/configurable-leistungsnachweis-generation/specs/project-rest-api/spec.md new file mode 100644 index 000000000..5b63b4dc7 --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/specs/project-rest-api/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Project lead can list their own projects +The system SHALL expose a `GET /projects` endpoint that returns the projects for which the authenticated user is a lead. The endpoint SHALL be restricted to the `PROJECT_LEAD` role. Because the result set is scoped to the caller's own led projects, the role restriction alone is sufficient authorization for reading. The response SHALL include, for each project, its identifier, ZEP id, name, `billable` flag, and `leistungsnachweisEnabled` flag. + +#### Scenario: Project lead requests their projects +- **WHEN** an authenticated `PROJECT_LEAD` user calls `GET /projects` +- **THEN** the system returns `200 OK` with the list of projects the user leads +- **THEN** each entry includes `id`, `zepId`, `name`, `billable`, and `leistungsnachweisEnabled` + +#### Scenario: Project lead leads no projects +- **WHEN** an authenticated `PROJECT_LEAD` user who leads no projects calls `GET /projects` +- **THEN** the system returns `200 OK` with an empty list + +#### Scenario: Non-project-lead user is rejected +- **WHEN** an authenticated user without the `PROJECT_LEAD` role calls `GET /projects` +- **THEN** the system returns `403 Forbidden` + +### Requirement: Project lead can toggle Leistungsnachweis generation for a project they lead +The system SHALL expose a `PUT /projects/{projectId}/leistungsnachweis-enabled` endpoint that sets the project's `leistungsnachweisEnabled` flag from a request body carrying an `enabled` boolean. The endpoint SHALL be restricted to the `PROJECT_LEAD` role. In addition to the role restriction, the system SHALL verify that the authenticated user is a lead of the specific target project; a user who is not a lead of that project SHALL be rejected even if they hold the `PROJECT_LEAD` role. On success the flag is persisted and takes effect from the next month-end generation run. + +#### Scenario: Lead disables Leistungsnachweis for their project +- **WHEN** an authenticated `PROJECT_LEAD` user who is a lead of the target project calls `PUT /projects/{projectId}/leistungsnachweis-enabled` with `enabled=false` +- **THEN** the system persists `leistungsnachweisEnabled=false` for that project +- **THEN** the system returns a success response + +#### Scenario: Lead re-enables Leistungsnachweis for their project +- **WHEN** an authenticated `PROJECT_LEAD` user who is a lead of the target project calls the endpoint with `enabled=true` +- **THEN** the system persists `leistungsnachweisEnabled=true` for that project + +#### Scenario: Lead of a different project is rejected +- **WHEN** an authenticated `PROJECT_LEAD` user who is NOT a lead of the target project calls the endpoint +- **THEN** the system rejects the request with an authorization error +- **THEN** the project's `leistungsnachweisEnabled` flag is unchanged + +#### Scenario: Non-project-lead user is rejected +- **WHEN** an authenticated user without the `PROJECT_LEAD` role calls the endpoint +- **THEN** the system returns `403 Forbidden` + +#### Scenario: Unknown project identifier +- **WHEN** the `projectId` does not correspond to an existing project +- **THEN** the system returns a not-found error diff --git a/openspec/changes/configurable-leistungsnachweis-generation/tasks.md b/openspec/changes/configurable-leistungsnachweis-generation/tasks.md new file mode 100644 index 000000000..153bbb992 --- /dev/null +++ b/openspec/changes/configurable-leistungsnachweis-generation/tasks.md @@ -0,0 +1,42 @@ +## 1. Domain: flag on the Project aggregate + +- [x] 1.1 Add `boolean leistungsnachweisEnabled` field to the `Project` record; update the canonical constructor and null/validation checks +- [x] 1.2 Set `leistungsnachweisEnabled = true` in `Project.create(ProjectId, ZepProjectProfile)` +- [x] 1.3 Preserve the flag in `withSyncedZepData(...)` and `withLeads(...)` +- [x] 1.4 Add `withLeistungsnachweisEnabled(boolean)` returning a new instance with all other fields preserved +- [x] 1.5 Unit-test create default (true), resync preservation, lead-sync preservation, and toggle transition + +## 2. Persistence + +- [x] 2.1 Add `leistungsnachweis_enabled` boolean column (not null) to `ProjectEntity` +- [x] 2.2 Add a Liquibase changelog adding the column with `defaultValueBoolean: true`; register it in `changelog-master.xml` +- [x] 2.3 Update the Project entity↔domain MapStruct mapper to carry the flag both ways +- [x] 2.4 Integration-test round-trip persistence and that existing rows backfill to `true` + +## 3. Month-end snapshot + generation gating + +- [x] 3.1 Add `boolean leistungsnachweisEnabled` to `MonthEndProjectSnapshot` +- [x] 3.2 Update `MonthEndProjectSnapshotMapper` to map the flag from `Project` +- [x] 3.3 Gate `LEISTUNGSNACHWEIS` creation in `MonthEndTaskPlanningService.planProjectTasks` on `project.leistungsnachweisEnabled()` (in addition to billable + active leads) +- [x] 3.4 Unit-test planning: flag=true generates Leistungsnachweis; flag=false suppresses it while `PROJECT_LEAD_REVIEW` and `ABRECHNUNG` are still generated +- [x] 3.5 Verify (test) that disabling the flag does not affect already-generated tasks and only the next run is affected + +## 4. Application: use cases (project BC) + +- [x] 4.1 Add inbound port `GetLeadProjectsUseCase` returning the caller's led projects; implement service using `ProjectRepository.findAllByLead(actorId)` +- [x] 4.2 Add inbound port `SetLeistungsnachweisEnabledUseCase(projectId, enabled, actorId)`; implement service: load project (not-found error if absent), assert `project.leads().contains(actorId)` else authorization error, apply `withLeistungsnachweisEnabled`, save +- [x] 4.3 Unit-test the toggle service: success path, non-lead rejection (flag unchanged), unknown project + +## 5. Inbound REST adapter (project BC) + +- [x] 5.1 Add OpenAPI paths file for projects (`GET /projects`, `PUT /projects/{projectId}/leistungsnachweis-enabled`) and register it in `openapi.yaml` +- [x] 5.2 Add OpenAPI schemas: project list item (`id`, `zepId`, `name`, `billable`, `leistungsnachweisEnabled`) and toggle request (`enabled`) +- [x] 5.3 Implement `ProjectResource` (first inbound adapter in the project BC) against the generated `ProjectApi`, with `@MegaRolesAllowed(Role.PROJECT_LEAD)` and actor from `AuthenticatedActorContext` +- [x] 5.4 Add REST mapper (domain → project list DTO) via MapStruct +- [x] 5.5 Map the not-found and authorization domain errors to appropriate HTTP responses (`404` / `403`) + +## 6. Verification + +- [x] 6.1 REST test `GET /projects`: returns only the caller's led projects; empty list; `403` for non-lead role +- [x] 6.2 REST test `PUT .../leistungsnachweis-enabled`: lead toggles own project; `403` for wrong-project lead; not-found for unknown project +- [x] 6.3 Run `mvn clean package` (ArchUnit + full suite) and confirm green diff --git a/openspec/specs/notification-zep-mail-processing/spec.md b/openspec/specs/notification-zep-mail-processing/spec.md deleted file mode 100644 index e9c93e5b7..000000000 --- a/openspec/specs/notification-zep-mail-processing/spec.md +++ /dev/null @@ -1,27 +0,0 @@ -# Notification ZEP Mail Processing - -## Purpose - -ZEP mail processing has been relocated to the monthend BC. This spec retains the original requirement definitions as REMOVED notices for migration reference. See `monthend-zep-mail-processing` for the current specification. - -## Requirements - -### Requirement: ZEP mail webhook triggers the ZEP mail processing use case -> **REMOVED** — ZEP mail processing is not a notification concern. The webhook and use case have been relocated to the monthend BC as `ZepMailWebhookResource` and `CreateClarificationFromZepMailUseCase`. -> **Migration**: See `monthend-zep-mail-processing` spec. The REST contract (`/pubsub/message-received`) is unchanged. - -### Requirement: ZEP mail processing use case fetches, parses, and publishes a domain event per valid message -> **REMOVED** — The `ProcessZepMailUseCase` and `ProcessZepMailService` are replaced by `CreateClarificationFromZepMailUseCase` in the monthend BC. The `ZepClarificationMailReceived` integration event is deleted; the new service calls the monthend domain directly. Error notifications are now triggered via `ZepMailProcessingFailedEvent`. -> **Migration**: See `monthend-zep-mail-processing` spec. - -### Requirement: ZepClarificationMailReceived is an integration event in the shared domain -> **REMOVED** — The event existed solely to bridge the incorrect BC split. With both producer and consumer in the monthend BC the event is unnecessary. Removing it reduces shared kernel coupling. -> **Migration**: Delete `ZepClarificationMailReceived` from `shared/domain/event/` and delete `ZepClarificationMailAdapter` from `monthend/adapter/inbound/`. No replacement needed — the new use case calls domain directly. - -### Requirement: ZepMailboxPort returns domain-typed raw mails; ZepMailMessageParser is a domain service -> **REMOVED** — These artefacts are relocated to the monthend BC. The requirements are unchanged in substance; see `monthend-zep-mail-processing` spec. -> **Migration**: Move all types to `monthend/domain/` with updated package declarations. - -### Requirement: ZepMailboxPort abstracts IMAP inbox access -> **REMOVED** — Relocated to monthend BC. See `monthend-zep-mail-processing` spec. -> **Migration**: Move `ImapZepMailboxAdapter` to `monthend/adapter/outbound/` and `ZepMailboxPort` to `monthend/domain/port/outbound/`. diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/LeistungsnachweisDisabledTaskCloser.java b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/LeistungsnachweisDisabledTaskCloser.java new file mode 100644 index 000000000..a07e2fb6a --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/LeistungsnachweisDisabledTaskCloser.java @@ -0,0 +1,34 @@ +package com.gepardec.mega.hexagon.monthend.adapter.inbound; + +import com.gepardec.mega.hexagon.monthend.application.port.inbound.CloseLeistungsnachweisTasksForProjectUseCase; +import com.gepardec.mega.hexagon.project.domain.event.LeistungsnachweisDisabledEvent; +import io.quarkus.logging.Log; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +import java.time.Clock; +import java.time.YearMonth; + +@ApplicationScoped +public class LeistungsnachweisDisabledTaskCloser { + + private final CloseLeistungsnachweisTasksForProjectUseCase closeLeistungsnachweisTasksForProjectUseCase; + private final Clock clock; + + @Inject + public LeistungsnachweisDisabledTaskCloser( + CloseLeistungsnachweisTasksForProjectUseCase closeLeistungsnachweisTasksForProjectUseCase, + Clock clock + ) { + this.closeLeistungsnachweisTasksForProjectUseCase = closeLeistungsnachweisTasksForProjectUseCase; + this.clock = clock; + } + + void onLeistungsnachweisDisabled(@Observes LeistungsnachweisDisabledEvent event) { + YearMonth currentMonth = YearMonth.from(clock.instant().atZone(clock.getZone())); + Log.infof("Closing open LEISTUNGSNACHWEIS tasks for project %s in %s", + event.projectId().value(), currentMonth); + closeLeistungsnachweisTasksForProjectUseCase.closeOpenTasks(event.projectId(), currentMonth); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResource.java b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResource.java index 50df2d99e..a281a4a4a 100644 --- a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResource.java +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResource.java @@ -258,7 +258,32 @@ public Response generateMonthEndTasks(String month) { return Response.ok(monthEndRestMapper.toDto(result)).build(); } - private Map resolveProjectRefs(List tasks, YearMonth month) { + private Map resolveUserRefs(Set ids, YearMonth month) { + if (ids.isEmpty()) { + return Map.of(); + } + return userSnapshotPort.findByIds(ids, month).stream() + .collect(Collectors.toMap(UserRef::id, Function.identity())); + } + + private MonthEndStatusOverviewDto toOverviewResponse( + MonthEndStatusOverview overview, + UserId actorId + ) { + List tasks = overview.tasks(); + Map snapshotsById = resolveProjectSnapshots(tasks, overview.month()); + Map projectRefs = toProjectRefs(snapshotsById); + Map leistungsnachweisEnabledByProject = toLeistungsnachweisEnabled(snapshotsById); + Map userRefs = resolveUserRefs(overviewUserIds(overview), overview.month()); + return monthEndRestMapper.toDto( + overview, projectRefs, userRefs, leistungsnachweisEnabledByProject, actorId, zepConfig + ); + } + + private Map resolveProjectSnapshots( + List tasks, + YearMonth month + ) { if (tasks.isEmpty()) { return Map.of(); } @@ -266,27 +291,27 @@ private Map resolveProjectRefs(List tasks, .map(MonthEndTask::projectId) .collect(Collectors.toSet()); return projectSnapshotPort.findByIds(projectIds, month).stream() + .collect(Collectors.toMap(MonthEndProjectSnapshot::id, Function.identity())); + } + + private static Map toProjectRefs( + Map snapshots + ) { + return snapshots.values().stream() .collect(Collectors.toMap( MonthEndProjectSnapshot::id, snapshot -> new ProjectRef(snapshot.id(), snapshot.zepId(), snapshot.name()) )); } - private Map resolveUserRefs(Set ids, YearMonth month) { - if (ids.isEmpty()) { - return Map.of(); - } - return userSnapshotPort.findByIds(ids, month).stream() - .collect(Collectors.toMap(UserRef::id, Function.identity())); - } - - private MonthEndStatusOverviewDto toOverviewResponse( - MonthEndStatusOverview overview, - UserId actorId + private static Map toLeistungsnachweisEnabled( + Map snapshots ) { - Map projectRefs = resolveProjectRefs(overview.tasks(), overview.month()); - Map userRefs = resolveUserRefs(overviewUserIds(overview), overview.month()); - return monthEndRestMapper.toDto(overview, projectRefs, userRefs, actorId, zepConfig); + return snapshots.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().leistungsnachweisEnabled() + )); } private static Set overviewUserIds(MonthEndStatusOverview overview) { diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapper.java b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapper.java index 8e50ea1c3..5aadae814 100644 --- a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapper.java +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapper.java @@ -50,12 +50,14 @@ public interface MonthEndRestMapper { @Mapping(target = "taskId", source = "id") @Mapping(target = "project", ignore = true) @Mapping(target = "subjectEmployee", ignore = true) + @Mapping(target = "leistungsnachweisEnabled", ignore = true) @Mapping(target = "canComplete", ignore = true) @Mapping(target = "completedBy", ignore = true) MonthEndStatusOverviewEntryDto toEntry( MonthEndTask task, @Context Map projectRefs, @Context Map userRefs, + @Context Map leistungsnachweisEnabledByProject, @Context UserId actorId, @Context ZepConfig zepConfig ); @@ -66,6 +68,7 @@ default void enrichTaskEntry( @MappingTarget MonthEndStatusOverviewEntryDto entry, @Context Map projectRefs, @Context Map userRefs, + @Context Map leistungsnachweisEnabledByProject, @Context UserId actorId, @Context ZepConfig zepConfig ) { @@ -74,11 +77,12 @@ default void enrichTaskEntry( throw new IllegalStateException( "project snapshot not found for project " + task.projectId().value()); } - UserRef subjectEmployee = task.subjectEmployeeId() != null + UserRef subjectUser = task.subjectEmployeeId() != null ? userRefs.get(task.subjectEmployeeId()) : null; entry.project(toDto(project, zepConfig)) - .subjectEmployee(subjectEmployee != null ? toDto(subjectEmployee, zepConfig) : null) + .subjectEmployee(subjectUser != null ? toDto(subjectUser, zepConfig) : null) + .leistungsnachweisEnabled(leistungsnachweisEnabledByProject.get(task.projectId())) .canComplete(task.canBeCompletedBy(actorId)) .completedBy(map(task.completedBy())); } @@ -87,6 +91,7 @@ MonthEndStatusOverviewDto toDto( MonthEndStatusOverview overview, @Context Map projectRefs, @Context Map userRefs, + @Context Map leistungsnachweisEnabledByProject, @Context UserId actorId, @Context ZepConfig zepConfig ); diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapter.java b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapter.java index 9074012cb..951ecbb41 100644 --- a/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapter.java +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapter.java @@ -111,6 +111,20 @@ public List findOpenSubjectTasks(UserId subjectId, YearMonth month .toList(); } + @Override + public List findOpenLeistungsnachweisTasks(YearMonth month, ProjectId projectId) { + return panache.find( + "monthValue = ?1 and projectId = ?2 and type = ?3 and status = ?4", + toMonthValue(month), + projectId.value(), + MonthEndTaskType.LEISTUNGSNACHWEIS, + MonthEndTaskStatus.OPEN + ) + .list().stream() + .map(mapper::toDomain) + .toList(); + } + @Override public void save(MonthEndTask task) { upsert(task); diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/application/CloseLeistungsnachweisTasksForProjectService.java b/src/main/java/com/gepardec/mega/hexagon/monthend/application/CloseLeistungsnachweisTasksForProjectService.java new file mode 100644 index 000000000..f3c9c3846 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/application/CloseLeistungsnachweisTasksForProjectService.java @@ -0,0 +1,41 @@ +package com.gepardec.mega.hexagon.monthend.application; + +import com.gepardec.mega.hexagon.monthend.application.port.inbound.CloseLeistungsnachweisTasksForProjectUseCase; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTask; +import com.gepardec.mega.hexagon.monthend.domain.port.outbound.MonthEndTaskRepository; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.time.YearMonth; +import java.util.List; +import java.util.Objects; + +@ApplicationScoped +@Transactional +public class CloseLeistungsnachweisTasksForProjectService implements CloseLeistungsnachweisTasksForProjectUseCase { + + private final MonthEndTaskRepository monthEndTaskRepository; + + @Inject + public CloseLeistungsnachweisTasksForProjectService(MonthEndTaskRepository monthEndTaskRepository) { + this.monthEndTaskRepository = monthEndTaskRepository; + } + + @Override + public void closeOpenTasks(ProjectId projectId, YearMonth month) { + Objects.requireNonNull(projectId, "projectId must not be null"); + Objects.requireNonNull(month, "month must not be null"); + + List openTasks = monthEndTaskRepository.findOpenLeistungsnachweisTasks(month, projectId); + if (openTasks.isEmpty()) { + return; + } + + List completedTasks = openTasks.stream() + .map(MonthEndTask::completeBySystem) + .toList(); + monthEndTaskRepository.saveAll(completedTasks); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/application/port/inbound/CloseLeistungsnachweisTasksForProjectUseCase.java b/src/main/java/com/gepardec/mega/hexagon/monthend/application/port/inbound/CloseLeistungsnachweisTasksForProjectUseCase.java new file mode 100644 index 000000000..12be68a1c --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/application/port/inbound/CloseLeistungsnachweisTasksForProjectUseCase.java @@ -0,0 +1,10 @@ +package com.gepardec.mega.hexagon.monthend.application.port.inbound; + +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; + +import java.time.YearMonth; + +public interface CloseLeistungsnachweisTasksForProjectUseCase { + + void closeOpenTasks(ProjectId projectId, YearMonth month); +} \ No newline at end of file diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/domain/model/MonthEndProjectSnapshot.java b/src/main/java/com/gepardec/mega/hexagon/monthend/domain/model/MonthEndProjectSnapshot.java index a4535079e..114573a52 100644 --- a/src/main/java/com/gepardec/mega/hexagon/monthend/domain/model/MonthEndProjectSnapshot.java +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/domain/model/MonthEndProjectSnapshot.java @@ -11,6 +11,7 @@ public record MonthEndProjectSnapshot( int zepId, String name, boolean billable, + boolean leistungsnachweisEnabled, Set leadIds ) { diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/domain/port/outbound/MonthEndTaskRepository.java b/src/main/java/com/gepardec/mega/hexagon/monthend/domain/port/outbound/MonthEndTaskRepository.java index b6c0cf5ae..2ce8b2ba1 100644 --- a/src/main/java/com/gepardec/mega/hexagon/monthend/domain/port/outbound/MonthEndTaskRepository.java +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/domain/port/outbound/MonthEndTaskRepository.java @@ -25,6 +25,8 @@ public interface MonthEndTaskRepository { List findOpenSubjectTasks(UserId subjectId, YearMonth month); + List findOpenLeistungsnachweisTasks(YearMonth month, ProjectId projectId); + void save(MonthEndTask task); void saveAll(List tasks); diff --git a/src/main/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningService.java b/src/main/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningService.java index 1c216528d..86d0ccfd9 100644 --- a/src/main/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningService.java +++ b/src/main/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningService.java @@ -38,7 +38,8 @@ public List planProjectTasks( )); } - if (project.billable() && !activeLeadIds.isEmpty()) { + + if (project.leistungsnachweisEnabled() && project.billable() && !activeLeadIds.isEmpty()) { tasks.add(MonthEndTask.create( MonthEndTaskId.generate(), month, diff --git a/src/main/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectResource.java b/src/main/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectResource.java new file mode 100644 index 000000000..afd629f4f --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectResource.java @@ -0,0 +1,79 @@ +package com.gepardec.mega.hexagon.project.adapter.inbound.rest; + + +import com.gepardec.mega.hexagon.generated.api.ProjectApi; +import com.gepardec.mega.hexagon.generated.model.ApiErrorDto; +import com.gepardec.mega.hexagon.generated.model.LeistungsnachweisToggleRequestDto; +import com.gepardec.mega.hexagon.generated.model.ProjectItemDto; +import com.gepardec.mega.hexagon.project.application.port.inbound.GetLeadProjectsUseCase; +import com.gepardec.mega.hexagon.project.application.port.inbound.SetLeistungsnachweisEnabledUseCase; +import com.gepardec.mega.hexagon.shared.application.security.AuthenticatedActorContext; +import com.gepardec.mega.hexagon.shared.application.security.ForbiddenException; +import com.gepardec.mega.hexagon.shared.application.security.MegaRolesAllowed; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import jakarta.inject.Inject; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.UUID; + + +public class ProjectResource implements ProjectApi { + private final GetLeadProjectsUseCase getLeadProjectsUseCase; + private final SetLeistungsnachweisEnabledUseCase setLeistungsnachweisEnabledUseCase; + private final AuthenticatedActorContext authenticatedActorContext; + private final ProjectRestMapper projectRestMapper; + + @Inject + public ProjectResource(GetLeadProjectsUseCase getLeadProjectsUseCase, + SetLeistungsnachweisEnabledUseCase setLeistungsnachweisEnabledUseCase, + AuthenticatedActorContext authenticatedActorContext, + ProjectRestMapper projectRestMapper) { + this.getLeadProjectsUseCase = getLeadProjectsUseCase; + this.setLeistungsnachweisEnabledUseCase = setLeistungsnachweisEnabledUseCase; + this.authenticatedActorContext = authenticatedActorContext; + this.projectRestMapper = projectRestMapper; + } + + @Override + @MegaRolesAllowed(Role.PROJECT_LEAD) + public Response getLeadProjects() { + var actorId = authenticatedActorContext.userId(); + var projects = getLeadProjectsUseCase.getLeadProjects(actorId); + List dtos = projectRestMapper.toDtoList(projects); + + return Response.ok(dtos).build(); + } + + @Override + @MegaRolesAllowed(Role.PROJECT_LEAD) + public Response setLeistungsnachweisEnabled( + @PathParam("projectId") UUID projectId, + LeistungsnachweisToggleRequestDto leistungsnachweisToggleRequestDto) { + + Boolean enabled = leistungsnachweisToggleRequestDto.getEnabled(); + if (enabled == null) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new ApiErrorDto().message("'enabled' field is required")) + .build(); + } + try { + setLeistungsnachweisEnabledUseCase.setLeistungsnachweisEnabled( + ProjectId.of(projectId), + authenticatedActorContext.userId(), + enabled + ); + return Response.noContent().build(); + } catch (IllegalArgumentException exception) { + return Response.status(Response.Status.NOT_FOUND) + .entity(new ApiErrorDto().message(exception.getMessage())) + .build(); + } catch (ForbiddenException exception) { + return Response.status(Response.Status.FORBIDDEN) + .entity(new ApiErrorDto().message(exception.getMessage())) + .build(); + } + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectRestMapper.java b/src/main/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectRestMapper.java new file mode 100644 index 000000000..4ff449cfc --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectRestMapper.java @@ -0,0 +1,24 @@ +package com.gepardec.mega.hexagon.project.adapter.inbound.rest; + +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.generated.model.ProjectItemDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +@Mapper(componentModel = MappingConstants.ComponentModel.JAKARTA) +public interface ProjectRestMapper { + + ProjectRestMapper INSTANCE = Mappers.getMapper(ProjectRestMapper.class); + + @Mapping(target = "id", source = "id.value") + ProjectItemDto toDto(Project project); + + default List toDtoList(List projects) { + return projects == null ? List.of() + : projects.stream().map(this::toDto).toList(); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectEntity.java b/src/main/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectEntity.java index 56a5c3bee..8fa9b42fd 100644 --- a/src/main/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectEntity.java +++ b/src/main/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectEntity.java @@ -37,6 +37,9 @@ public class ProjectEntity { @Column(name = "billable", nullable = false) private boolean billable; + @Column(name = "leistungsnachweis_enabled", nullable = false) + private boolean leistungsnachweisEnabled = true; + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "project_leads", @@ -93,6 +96,14 @@ public void setBillable(boolean billable) { this.billable = billable; } + public boolean isLeistungsnachweisEnabled() { + return leistungsnachweisEnabled; + } + + public void setLeistungsnachweisEnabled(boolean leistungsnachweisEnabled) { + this.leistungsnachweisEnabled = leistungsnachweisEnabled; + } + public Set getLeads() { return leads; } diff --git a/src/main/java/com/gepardec/mega/hexagon/project/application/ProjectSettingsService.java b/src/main/java/com/gepardec/mega/hexagon/project/application/ProjectSettingsService.java new file mode 100644 index 000000000..4f0f30cd9 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/project/application/ProjectSettingsService.java @@ -0,0 +1,54 @@ +package com.gepardec.mega.hexagon.project.application; + +import com.gepardec.mega.hexagon.project.domain.event.LeistungsnachweisDisabledEvent; +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.project.application.port.inbound.GetLeadProjectsUseCase; +import com.gepardec.mega.hexagon.project.application.port.inbound.SetLeistungsnachweisEnabledUseCase; +import com.gepardec.mega.hexagon.project.domain.port.outbound.ProjectRepository; +import com.gepardec.mega.hexagon.shared.application.security.ForbiddenException; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; + +import java.util.List; +import java.util.Set; + +@ApplicationScoped +@Transactional +public class ProjectSettingsService implements GetLeadProjectsUseCase, SetLeistungsnachweisEnabledUseCase { + private final ProjectRepository projectRepository; + private final Event leistungsnachweisDisabledEvent; + + @Inject + public ProjectSettingsService( + ProjectRepository projectRepository, + Event leistungsnachweisDisabledEvent + ) { + this.projectRepository = projectRepository; + this.leistungsnachweisDisabledEvent = leistungsnachweisDisabledEvent; + } + + @Override + public List getLeadProjects(UserId actorId) { + return projectRepository.findAllByLead(actorId); + } + + @Override + public void setLeistungsnachweisEnabled(ProjectId projectId, UserId actorId, boolean enabled) { + List projects = projectRepository.findAllByIds(Set.of(projectId)); + if(projects.isEmpty()) throw new IllegalArgumentException("Project not found: " + projectId); + + Project project = projects.getFirst(); + if(!project.leads().contains(actorId)) throw new ForbiddenException("Actor is not a lead " + actorId); + + Project updated = project.withLeistungsnachweisEnabled(enabled); + projectRepository.saveAll(List.of(updated)); + + if(!enabled) { + leistungsnachweisDisabledEvent.fire(new LeistungsnachweisDisabledEvent(projectId)); + } + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/project/application/port/inbound/GetLeadProjectsUseCase.java b/src/main/java/com/gepardec/mega/hexagon/project/application/port/inbound/GetLeadProjectsUseCase.java new file mode 100644 index 000000000..a6d20bcd9 --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/project/application/port/inbound/GetLeadProjectsUseCase.java @@ -0,0 +1,10 @@ +package com.gepardec.mega.hexagon.project.application.port.inbound; + +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; + +import java.util.List; + +public interface GetLeadProjectsUseCase { + List getLeadProjects(UserId actorId); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/project/application/port/inbound/SetLeistungsnachweisEnabledUseCase.java b/src/main/java/com/gepardec/mega/hexagon/project/application/port/inbound/SetLeistungsnachweisEnabledUseCase.java new file mode 100644 index 000000000..8f21ee30f --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/project/application/port/inbound/SetLeistungsnachweisEnabledUseCase.java @@ -0,0 +1,8 @@ +package com.gepardec.mega.hexagon.project.application.port.inbound; + +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; + +public interface SetLeistungsnachweisEnabledUseCase { + void setLeistungsnachweisEnabled(ProjectId projectId, UserId actorId, boolean enabled); +} diff --git a/src/main/java/com/gepardec/mega/hexagon/project/domain/event/LeistungsnachweisDisabledEvent.java b/src/main/java/com/gepardec/mega/hexagon/project/domain/event/LeistungsnachweisDisabledEvent.java new file mode 100644 index 000000000..3d6d7e38c --- /dev/null +++ b/src/main/java/com/gepardec/mega/hexagon/project/domain/event/LeistungsnachweisDisabledEvent.java @@ -0,0 +1,12 @@ +package com.gepardec.mega.hexagon.project.domain.event; + +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; + +import java.util.Objects; + +public record LeistungsnachweisDisabledEvent(ProjectId projectId) { + + public LeistungsnachweisDisabledEvent { + Objects.requireNonNull(projectId, "projectId must not be null"); + } +} diff --git a/src/main/java/com/gepardec/mega/hexagon/project/domain/model/Project.java b/src/main/java/com/gepardec/mega/hexagon/project/domain/model/Project.java index 0c7fbfd32..8ab60fca7 100644 --- a/src/main/java/com/gepardec/mega/hexagon/project/domain/model/Project.java +++ b/src/main/java/com/gepardec/mega/hexagon/project/domain/model/Project.java @@ -15,6 +15,7 @@ public record Project( LocalDate startDate, LocalDate endDate, boolean billable, + boolean leistungsnachweisEnabled, Set leads ) { @@ -26,15 +27,19 @@ public record Project( } public static Project create(ProjectId id, ZepProjectProfile profile) { - return new Project(id, profile.zepId(), profile.name(), profile.startDate(), profile.endDate(), profile.billable(), Set.of()); + return new Project(id, profile.zepId(), profile.name(), profile.startDate(), profile.endDate(), profile.billable(), true, Set.of()); } public Project withSyncedZepData(ZepProjectProfile profile) { - return new Project(id, profile.zepId(), profile.name(), profile.startDate(), profile.endDate(), profile.billable(), leads); + return new Project(id, profile.zepId(), profile.name(), profile.startDate(), profile.endDate(), profile.billable(), leistungsnachweisEnabled ,leads); } public Project withLeads(Set updatedLeads) { - return new Project(id, zepId, name, startDate, endDate, billable, updatedLeads); + return new Project(id, zepId, name, startDate, endDate, billable, leistungsnachweisEnabled, updatedLeads); + } + + public Project withLeistungsnachweisEnabled(boolean enabled) { + return new Project(id, zepId, name, startDate, endDate, billable, enabled, leads); } public boolean isActiveIn(YearMonth month) { diff --git a/src/main/resources/db/changelog-master.xml b/src/main/resources/db/changelog-master.xml index 88fd726bc..c73384122 100644 --- a/src/main/resources/db/changelog-master.xml +++ b/src/main/resources/db/changelog-master.xml @@ -31,5 +31,6 @@ + diff --git a/src/main/resources/db/changelog/hexagon/015-disable-leistungsnachweis.yaml b/src/main/resources/db/changelog/hexagon/015-disable-leistungsnachweis.yaml new file mode 100644 index 000000000..1554fec41 --- /dev/null +++ b/src/main/resources/db/changelog/hexagon/015-disable-leistungsnachweis.yaml @@ -0,0 +1,15 @@ +databaseChangeLog: + - changeSet: + id: hexagon-disable-leistungsnachweis-001 + author: mega + changes: + - addColumn: + tableName: projects + columns: + - column: + name: leistungsnachweis_enabled + type: boolean + constraints: + nullable: false + defaultValueBoolean: true + diff --git a/src/main/resources/openapi/openapi.yaml b/src/main/resources/openapi/openapi.yaml index ecef32cda..b8907effe 100644 --- a/src/main/resources/openapi/openapi.yaml +++ b/src/main/resources/openapi/openapi.yaml @@ -12,6 +12,8 @@ tags: description: Employee-scoped work time endpoints - name: WorkTimeProjectLead description: Project-lead-scoped work time endpoints + - name: Project + description: Project endpoints for leistungsnachweis paths: /monthend/payroll-month/employee: $ref: './paths/monthend.yaml#/~1monthend~1payroll-month~1employee' @@ -49,6 +51,10 @@ paths: $ref: './paths/worktime.yaml#/~1worktime~1employee~1{payrollMonth}' /worktime/projects/{payrollMonth}: $ref: './paths/worktime.yaml#/~1worktime~1projects~1{payrollMonth}' + /projects: + $ref: './paths/projects.yaml#/~1projects' + /projects/{projectId}/leistungsnachweis-enabled: + $ref: './paths/projects.yaml#/~1projects~1{projectId}~1leistungsnachweis-enabled' components: schemas: ActiveUser: @@ -63,6 +69,10 @@ components: $ref: './schemas/user.yaml#/UpdateReleaseDatesResponse' InternalRateUploadError: $ref: './schemas/user.yaml#/InternalRateUploadError' + ProjectItem: + $ref: './schemas/projects.yaml#/ProjectItem' + LeistungsnachweisToggleRequest: + $ref: './schemas/projects.yaml#/LeistungsnachweisToggleRequest' securitySchemes: bearerAuth: type: oauth2 diff --git a/src/main/resources/openapi/paths/projects.yaml b/src/main/resources/openapi/paths/projects.yaml new file mode 100644 index 000000000..3f36a8506 --- /dev/null +++ b/src/main/resources/openapi/paths/projects.yaml @@ -0,0 +1,43 @@ +'/projects': + get: + tags: + - Project + operationId: getLeadProjects + summary: Get projects led by user + security: + - bearerAuth: [ ] + responses: + '200': + description: List of projects + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/projects.yaml#/ProjectItem' + '403': { $ref: '../responses/common.yaml#/Forbidden'} +'/projects/{projectId}/leistungsnachweis-enabled': + put: + tags: + - Project + operationId: setLeistungsnachweisEnabled + summary: Toggle leistungsnachweis flag + security: + - bearerAuth: [ ] + parameters: + - name: projectId + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/projects.yaml#/LeistungsnachweisToggleRequest' + responses: + '204': { description: Flag updated } + '403': { $ref: '../responses/common.yaml#/Forbidden' } + '404': { $ref: '../responses/common.yaml#/NotFound' } + + diff --git a/src/main/resources/openapi/schemas/monthend.yaml b/src/main/resources/openapi/schemas/monthend.yaml index bbaed6345..4f6e452d0 100644 --- a/src/main/resources/openapi/schemas/monthend.yaml +++ b/src/main/resources/openapi/schemas/monthend.yaml @@ -23,6 +23,7 @@ MonthEndStatusOverviewEntry: - type - status - project + - leistungsnachweisEnabled - canComplete properties: taskId: @@ -34,6 +35,8 @@ MonthEndStatusOverviewEntry: $ref: '#/MonthEndTaskStatus' project: $ref: './shared.yaml#/ProjectRef' + leistungsnachweisEnabled: + type: boolean canComplete: type: boolean subjectEmployee: diff --git a/src/main/resources/openapi/schemas/projects.yaml b/src/main/resources/openapi/schemas/projects.yaml new file mode 100644 index 000000000..6126a0b43 --- /dev/null +++ b/src/main/resources/openapi/schemas/projects.yaml @@ -0,0 +1,27 @@ +ProjectItem: + type: object + required: + - id + - zepId + - name + - billable + - leistungsnachweisEnabled + properties: + id: + type: string + format: uuid + zepId: + type: integer + name: + type: string + billable: + type: boolean + leistungsnachweisEnabled: + type: boolean +LeistungsnachweisToggleRequest: + type: object + required: + - enabled + properties: + enabled: + type: boolean \ No newline at end of file diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/LeistungsnachweisDisabledTaskCloserIT.java b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/LeistungsnachweisDisabledTaskCloserIT.java new file mode 100644 index 000000000..0ead977ab --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/LeistungsnachweisDisabledTaskCloserIT.java @@ -0,0 +1,102 @@ +package com.gepardec.mega.hexagon.monthend.adapter.inbound; + +import com.gepardec.mega.hexagon.monthend.adapter.outbound.MonthEndTaskRepositoryAdapter; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTask; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTaskId; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTaskStatus; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTaskType; +import com.gepardec.mega.hexagon.project.adapter.outbound.ProjectRepositoryAdapter; +import com.gepardec.mega.hexagon.project.application.ProjectSettingsService; +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import com.gepardec.mega.hexagon.user.adapter.outbound.UserRepositoryAdapter; +import com.gepardec.mega.hexagon.user.domain.model.EmploymentPeriod; +import com.gepardec.mega.hexagon.user.domain.model.EmploymentPeriods; +import com.gepardec.mega.hexagon.user.domain.model.User; +import com.gepardec.mega.hexagon.shared.domain.model.Email; +import com.gepardec.mega.hexagon.shared.domain.model.FullName; +import com.gepardec.mega.hexagon.shared.domain.model.ZepUsername; +import io.quarkus.test.TestTransaction; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@QuarkusTest +@TestTransaction +class LeistungsnachweisDisabledTaskCloserIT { + + @Inject + ProjectSettingsService projectSettingsService; + + @Inject + MonthEndTaskRepositoryAdapter monthEndTaskRepositoryAdapter; + + @Inject + ProjectRepositoryAdapter projectRepositoryAdapter; + + @Inject + UserRepositoryAdapter userRepositoryAdapter; + + @Test + void deactivatingLeistungsnachweis_shouldCompleteOpenTasksInDatabase() { + YearMonth month = YearMonth.from(Clock.fixed(Instant.parse("2023-11-03T10:00:00Z"), ZoneOffset.UTC).instant().atZone(ZoneOffset.UTC)); + User lead = user("lead", Set.of(Role.EMPLOYEE, Role.PROJECT_LEAD)); + User employee = user("employee", Set.of(Role.EMPLOYEE)); + userRepositoryAdapter.saveAll(List.of(lead, employee)); + + Project project = project(true).withLeads(Set.of(lead.id())); + projectRepositoryAdapter.saveAll(List.of(project)); + + MonthEndTask openTask = MonthEndTask.create( + MonthEndTaskId.generate(), month, + MonthEndTaskType.LEISTUNGSNACHWEIS, + project.id(), employee.id(), Set.of(lead.id()) + ); + monthEndTaskRepositoryAdapter.save(openTask); + + projectSettingsService.setLeistungsnachweisEnabled(project.id(), lead.id(), false); + + List tasks = monthEndTaskRepositoryAdapter.findOpenLeistungsnachweisTasks(month, project.id()); + assertThat(tasks).isEmpty(); + + List allTasks = monthEndTaskRepositoryAdapter.findByMonth(month).stream() + .filter(task -> task.projectId().equals(project.id())) + .filter(task -> task.type() == MonthEndTaskType.LEISTUNGSNACHWEIS) + .toList(); + assertThat(allTasks).hasSize(1); + assertThat(allTasks.getFirst().status()).isEqualTo(MonthEndTaskStatus.DONE); + assertThat(allTasks.getFirst().completedBy()).isNotNull(); + } + + private User user(String username, Set roles) { + return new User( + UserId.generate(), + Email.of(username + "@example.com"), + FullName.of("Test", "User"), + ZepUsername.of(username), + null, + new EmploymentPeriods(new EmploymentPeriod(LocalDate.of(2020, 1, 1), null)), + roles + ); + } + + private Project project(boolean billable) { + return Project.create( + ProjectId.generate(), + new com.gepardec.mega.hexagon.project.domain.model.ZepProjectProfile( + 42, "Project-42", LocalDate.of(2025, 1, 1), null, billable) + ); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResourceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResourceTest.java index c92e02ad3..157e4e530 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResourceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndResourceTest.java @@ -223,6 +223,7 @@ void getEmployeeMonthEndStatusOverview_shouldReturnMappedOverviewForEmployeeRole assertThat(entry.getProject().getId()).isEqualTo(PROJECT_ID.value()); assertThat(entry.getSubjectEmployee().getId()).isEqualTo(EMPLOYEE_ID.value()); assertThat(entry.getCanComplete()).isTrue(); + assertThat(entry.getLeistungsnachweisEnabled()).isTrue(); }); assertThat(response.getClarifications()).singleElement().satisfies(clarificationEntry -> { assertThat(clarificationEntry.getSubjectEmployee().getId()).isEqualTo(EMPLOYEE_ID.value()); @@ -312,6 +313,7 @@ void getProjectLeadMonthEndStatusOverview_shouldReturnMappedOverviewForLeadRole( assertThat(entry.getProject().getId()).isEqualTo(PROJECT_ID.value()); assertThat(entry.getSubjectEmployee()).isNull(); assertThat(entry.getCanComplete()).isTrue(); + assertThat(entry.getLeistungsnachweisEnabled()).isTrue(); }); assertThat(response.getClarifications()).singleElement().satisfies(clarificationEntry -> { assertThat(clarificationEntry.getSubjectEmployee().getId()).isEqualTo(EMPLOYEE_ID.value()); @@ -748,7 +750,7 @@ private MonthEndClarification leadSelfProjectLevelClarification(String text) { } private MonthEndProjectSnapshot projectSnapshot() { - return new MonthEndProjectSnapshot(PROJECT_ID, 77, PROJECT_NAME, true, Set.of(PROJECT_LEAD_ID)); + return new MonthEndProjectSnapshot(PROJECT_ID, 77, PROJECT_NAME, true, true,Set.of(PROJECT_LEAD_ID)); } private UserRef employeeRef() { diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapperTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapperTest.java index acec1ea2c..1b75e5e86 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapperTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/inbound/rest/MonthEndRestMapperTest.java @@ -126,6 +126,7 @@ leadId, leadRef() overview, Map.of(projectId, projectRef()), userRefs, + Map.of(projectId, true), employeeId, zepConfig ); @@ -138,6 +139,7 @@ leadId, leadRef() assertThat(entry.getSubjectEmployee().getId()).isEqualTo(employeeId.value()); assertThat(entry.getSubjectEmployee().getFullName()).isEqualTo(employeeName); assertThat(entry.getCanComplete()).isTrue(); + assertThat(entry.getLeistungsnachweisEnabled()).isTrue(); }); assertThat(response.getClarifications()).singleElement().satisfies(c -> { assertThat(c.getProjectId()).isEqualTo(projectId.value()); @@ -172,6 +174,7 @@ void toDto_shouldMapStatusOverviewCanCompleteFalseForSubjectOnlyEntry() { overview, Map.of(projectId, projectRef()), Map.of(), + Map.of(projectId, false), employeeId, zepConfig ); @@ -180,6 +183,35 @@ void toDto_shouldMapStatusOverviewCanCompleteFalseForSubjectOnlyEntry() { .satisfies(entry -> assertThat(entry.getCanComplete()).isFalse()); } + @Test + void toDto_shouldMapStatusOverviewLeistungsnachweisEnabledFalseForDisabledProject() { + MonthEndStatusOverview overview = new MonthEndStatusOverview( + employeeId, + month, + List.of(MonthEndTask.create( + MonthEndTaskId.of(Instancio.create(UUID.class)), + month, + MonthEndTaskType.EMPLOYEE_TIME_CHECK, + projectId, + employeeId, + Set.of(employeeId) + )), + List.of() + ); + + MonthEndStatusOverviewDto response = mapper.toDto( + overview, + Map.of(projectId, projectRef()), + Map.of(), + Map.of(projectId, false), + employeeId, + zepConfig + ); + + assertThat(response.getTasks()).singleElement() + .satisfies(entry -> assertThat(entry.getLeistungsnachweisEnabled()).isFalse()); + } + @Test void toDto_shouldOmitStatusOverviewSubjectEmployeeForAbrechnung() { MonthEndStatusOverview overview = new MonthEndStatusOverview( @@ -200,6 +232,7 @@ void toDto_shouldOmitStatusOverviewSubjectEmployeeForAbrechnung() { overview, Map.of(projectId, projectRef()), Map.of(), + Map.of(projectId, true), employeeId, zepConfig ); diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapterTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapterTest.java index 2abe9b8f7..71cd07c49 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapterTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/adapter/outbound/MonthEndTaskRepositoryAdapterTest.java @@ -236,9 +236,53 @@ void findLeadProjectTasks_shouldReturnAllTasksForProjectsTheLeadLeads() { assertThat(result).containsExactlyInAnyOrder(etcTask, plrTask, abrechnungTask); } + @Test + void findOpenLeistungsnachweisTasks_shouldReturnOnlyOpenLeistungsnachweisTasksForProjectAndMonth() { + YearMonth month = YearMonth.of(2026, 3); + User employee = user("employee", Set.of(Role.EMPLOYEE)); + User lead = user("lead", Set.of(Role.EMPLOYEE, Role.PROJECT_LEAD)); + userRepositoryAdapter.saveAll(List.of(employee, lead)); + + Project project = project(42, true); + Project otherProject = project(43, true); + projectRepositoryAdapter.saveAll(List.of(project, otherProject)); + + MonthEndTask openLnTask = MonthEndTask.create( + MonthEndTaskId.generate(), month, + MonthEndTaskType.LEISTUNGSNACHWEIS, + project.id(), employee.id(), Set.of(lead.id()) + ); + MonthEndTask doneLnTask = MonthEndTask.create( + MonthEndTaskId.generate(), month, + MonthEndTaskType.LEISTUNGSNACHWEIS, + project.id(), employee.id(), Set.of(lead.id()) + ).complete(lead.id()); + MonthEndTask otherProjectLnTask = MonthEndTask.create( + MonthEndTaskId.generate(), month, + MonthEndTaskType.LEISTUNGSNACHWEIS, + otherProject.id(), employee.id(), Set.of(lead.id()) + ); + MonthEndTask otherMonthLnTask = MonthEndTask.create( + MonthEndTaskId.generate(), month.plusMonths(1), + MonthEndTaskType.LEISTUNGSNACHWEIS, + project.id(), employee.id(), Set.of(lead.id()) + ); + MonthEndTask etcTask = MonthEndTask.create( + MonthEndTaskId.generate(), month, + MonthEndTaskType.EMPLOYEE_TIME_CHECK, + project.id(), employee.id(), Set.of(employee.id()) + ); + monthEndTaskRepositoryAdapter.saveAll( + List.of(openLnTask, doneLnTask, otherProjectLnTask, otherMonthLnTask, etcTask)); + + List result = monthEndTaskRepositoryAdapter.findOpenLeistungsnachweisTasks(month, project.id()); + + assertThat(result).containsExactly(openLnTask); + } + private User user(String username, Set roles) { return new User( - UserId.generate(), + UserId.generate(), Email.of(username + "@example.com"), FullName.of("Test", "User"), ZepUsername.of(username), diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/CloseLeistungsnachweisTasksForProjectServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/CloseLeistungsnachweisTasksForProjectServiceTest.java new file mode 100644 index 000000000..d4ca9394d --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/CloseLeistungsnachweisTasksForProjectServiceTest.java @@ -0,0 +1,77 @@ +package com.gepardec.mega.hexagon.monthend.application; + +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTask; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTaskId; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTaskStatus; +import com.gepardec.mega.hexagon.monthend.domain.model.MonthEndTaskType; +import com.gepardec.mega.hexagon.monthend.domain.port.outbound.MonthEndTaskRepository; +import com.gepardec.mega.hexagon.shared.domain.SystemActor; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.YearMonth; +import java.util.List; +import java.util.Set; +import java.util.UUID; + + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CloseLeistungsnachweisTasksForProjectServiceTest { + + private static final YearMonth MONTH = YearMonth.of(2026, 8); + + private MonthEndTaskRepository monthEndTaskRepository; + private CloseLeistungsnachweisTasksForProjectService service; + + @BeforeEach + void setUp() { + monthEndTaskRepository = mock(MonthEndTaskRepository.class); + service = new CloseLeistungsnachweisTasksForProjectService(monthEndTaskRepository); + } + + @Test + void closeOpenTasks_shouldCompleteAllOpenTasksBySystem() { + ProjectId projectId = ProjectId.generate(); + MonthEndTask openTask = openLeistungsnachweisTask(projectId); + + when(monthEndTaskRepository.findOpenLeistungsnachweisTasks(MONTH, projectId)).thenReturn(List.of(openTask)); + + service.closeOpenTasks(projectId, MONTH); + + verify(monthEndTaskRepository).saveAll(org.mockito.ArgumentMatchers.argThat(tasks -> { + MonthEndTask saved = tasks.getFirst(); + return saved.status() == MonthEndTaskStatus.DONE + && saved.completedBy().equals(SystemActor.USER_ID) + && saved.type() == MonthEndTaskType.LEISTUNGSNACHWEIS; + })); + } + + @Test + void closeOpenTasks_shouldDoNothing_whenNoOpenTasksExist() { + ProjectId projectId = ProjectId.generate(); + + when(monthEndTaskRepository.findOpenLeistungsnachweisTasks(MONTH, projectId)).thenReturn(List.of()); + + service.closeOpenTasks(projectId, MONTH); + + verify(monthEndTaskRepository, never()).saveAll(org.mockito.ArgumentMatchers.anyList()); + } + + private MonthEndTask openLeistungsnachweisTask(ProjectId projectId) { + UserId employeeId = UserId.of(UUID.randomUUID()); + return MonthEndTask.create( + MonthEndTaskId.generate(), + MONTH, + MonthEndTaskType.LEISTUNGSNACHWEIS, + projectId, + employeeId, + Set.of(UserId.of(UUID.randomUUID())) + ); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateClarificationFromZepMailServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateClarificationFromZepMailServiceTest.java index 9b4525356..3729426c2 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateClarificationFromZepMailServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateClarificationFromZepMailServiceTest.java @@ -232,7 +232,7 @@ private ZepMailParseResult parseResult(ZepProjektzeitEntry entry) { } private MonthEndProjectSnapshot projectSnapshot() { - return new MonthEndProjectSnapshot(projectId, 77, "Gepardec", true, Set.of(creatorId)); + return new MonthEndProjectSnapshot(projectId, 77, "Gepardec", true, true, Set.of(creatorId)); } private MonthEndEmployeeProjectContext employeeContext( diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateMonthEndClarificationServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateMonthEndClarificationServiceTest.java index 2d769cd3f..a4be602e1 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateMonthEndClarificationServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/CreateMonthEndClarificationServiceTest.java @@ -155,6 +155,7 @@ private MonthEndEmployeeProjectContext employeeContext(Set eligibleLeadI 77, "Project-77", true, + true, eligibleLeadIds ), new UserRef( @@ -174,6 +175,7 @@ private MonthEndProjectContext projectContext(Set eligibleLeadIds) { 77, "Project-77", true, + true, eligibleLeadIds ), eligibleLeadIds diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/GenerateMonthEndTasksServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/GenerateMonthEndTasksServiceTest.java index 6b4491576..cd03f04b2 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/application/GenerateMonthEndTasksServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/GenerateMonthEndTasksServiceTest.java @@ -256,6 +256,7 @@ private MonthEndProjectSnapshot activeProject(int zepId, boolean billable, Set leadIds) { 77, "Project-77", true, + true, leadIds ); } diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/MonthEndProjectContextServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/MonthEndProjectContextServiceTest.java index abc41e4f0..1c6b5a827 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/application/MonthEndProjectContextServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/MonthEndProjectContextServiceTest.java @@ -87,6 +87,7 @@ private MonthEndProjectSnapshot activeProject(Set leadIds) { 77, "Project-77", true, + true, leadIds ); } diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/PersistZepClarificationServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/PersistZepClarificationServiceTest.java index 275a16bf0..b8b58b0af 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/application/PersistZepClarificationServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/PersistZepClarificationServiceTest.java @@ -77,7 +77,7 @@ void persist_shouldSaveClarificationAndFireCreatedEvent() { private MonthEndEmployeeProjectContext employeeContext() { return new MonthEndEmployeeProjectContext( month, - new MonthEndProjectSnapshot(projectId, 77, "Gepardec", true, Set.of(creatorId)), + new MonthEndProjectSnapshot(projectId, 77, "Gepardec", true, true, Set.of(creatorId)), new UserRef(subjectEmployeeId, FullName.of("Max", "Mustermann"), ZepUsername.of("max.mustermann")), Set.of(creatorId) ); diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/application/PrematureMonthEndPreparationServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/application/PrematureMonthEndPreparationServiceTest.java index 3d940389e..9f603c538 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/application/PrematureMonthEndPreparationServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/application/PrematureMonthEndPreparationServiceTest.java @@ -151,6 +151,7 @@ private MonthEndProjectSnapshot project(int zepId, boolean billable, UserId... l zepId, "Project-" + zepId, billable, + true, Set.of(leadIds) ); } diff --git a/src/test/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningServiceTest.java index a0e069556..3d5f5ebbf 100644 --- a/src/test/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/monthend/domain/services/MonthEndTaskPlanningServiceTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.time.Month; import java.time.YearMonth; import java.util.LinkedHashSet; import java.util.List; @@ -34,7 +35,7 @@ void setUp() { @Test void planEmployeeOwnedTasks_shouldCreateOnlyTimeCheck_whenProjectIsNonBillable() { UserRef employee = activeUser("employee"); - MonthEndProjectSnapshot project = activeProject(false, Set.of()); + MonthEndProjectSnapshot project = activeProject(false, true, Set.of()); List tasks = service.planEmployeeOwnedTasks(month, project, employee); @@ -49,7 +50,7 @@ void planEmployeeOwnedTasks_shouldCreateOnlyTimeCheck_whenProjectIsNonBillable() @Test void planEmployeeOwnedTasks_shouldCreateOnlyTimeCheck_whenProjectIsBillable() { UserRef employee = activeUser("employee"); - MonthEndProjectSnapshot project = activeProject(true, Set.of()); + MonthEndProjectSnapshot project = activeProject(true, true, Set.of()); List tasks = service.planEmployeeOwnedTasks(month, project, employee); @@ -67,7 +68,7 @@ void planProjectTasks_shouldIncludeLeadReviewAndAbrechnung_whenProjectIsBillable UserRef employeeB = activeUser("employee-b"); UserId leadA = UserId.of(Instancio.create(UUID.class)); UserId leadB = UserId.of(Instancio.create(UUID.class)); - MonthEndProjectSnapshot project = activeProject(true, Set.of(leadA, leadB)); + MonthEndProjectSnapshot project = activeProject(true, true, Set.of(leadA, leadB)); List tasks = service.planProjectTasks( month, @@ -102,7 +103,7 @@ void planProjectTasks_shouldIncludeLeadReviewAndAbrechnung_whenProjectIsBillable @Test void planProjectTasks_shouldSkipLeadOwnedTasks_whenNoActiveLeadsExist() { UserRef employee = activeUser("employee"); - MonthEndProjectSnapshot project = activeProject(true, Set.of()); + MonthEndProjectSnapshot project = activeProject(true, true, Set.of()); List tasks = service.planProjectTasks(month, project, Set.of(), Set.of(employee)); @@ -114,7 +115,7 @@ void planProjectTasks_shouldSkipLeadOwnedTasks_whenNoActiveLeadsExist() { void planProjectTasks_shouldNotCreateLeistungsnachweis_whenProjectIsNonBillable() { UserRef employee = activeUser("employee"); UserId lead = UserId.of(Instancio.create(UUID.class)); - MonthEndProjectSnapshot project = activeProject(false, Set.of(lead)); + MonthEndProjectSnapshot project = activeProject(false, true, Set.of(lead)); List tasks = service.planProjectTasks(month, project, Set.of(lead), Set.of(employee)); @@ -125,6 +126,40 @@ void planProjectTasks_shouldNotCreateLeistungsnachweis_whenProjectIsNonBillable( ); } + @Test + void flagFalse_supressesLeistungsnachweis() { + UserRef employee = activeUser("employee"); + UserId lead = UserId.of(Instancio.create(UUID.class)); + MonthEndProjectSnapshot project = activeProject(true, false, Set.of(lead)); + + List tasks = service.planProjectTasks(month,project,Set.of(lead),Set.of(employee)); + + assertThat(tasks).extracting(MonthEndTask::type) + .doesNotContain(MonthEndTaskType.LEISTUNGSNACHWEIS); + assertThat(tasks).extracting(MonthEndTask::type) + .contains( + MonthEndTaskType.PROJECT_LEAD_REVIEW, + MonthEndTaskType.ABRECHNUNG + ); + + } + + + @Test + void flagTrue_containsLeistungsnachweis() { + UserRef employee = activeUser("employee"); + UserId lead = UserId.of(Instancio.create(UUID.class)); + MonthEndProjectSnapshot project = activeProject(true, true, Set.of(lead)); + + List tasks = service.planProjectTasks(month,project,Set.of(lead),Set.of(employee)); + + assertThat(tasks).extracting(MonthEndTask::type) + .contains( + MonthEndTaskType.LEISTUNGSNACHWEIS + ); + + } + private UserRef activeUser(String username) { return new UserRef( UserId.of(Instancio.create(UUID.class)), @@ -133,12 +168,13 @@ private UserRef activeUser(String username) { ); } - private MonthEndProjectSnapshot activeProject(boolean billable, Set leadIds) { + private MonthEndProjectSnapshot activeProject(boolean billable, boolean leistungsnachweisEnabled, Set leadIds) { return new MonthEndProjectSnapshot( ProjectId.of(Instancio.create(UUID.class)), 91, "Project-91", billable, + leistungsnachweisEnabled, leadIds ); } diff --git a/src/test/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectResourceTest.java b/src/test/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectResourceTest.java new file mode 100644 index 000000000..6dd47f1bc --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/project/adapter/inbound/rest/ProjectResourceTest.java @@ -0,0 +1,144 @@ +package com.gepardec.mega.hexagon.project.adapter.inbound.rest; + +import com.gepardec.mega.hexagon.generated.model.LeistungsnachweisToggleRequestDto; +import com.gepardec.mega.hexagon.project.application.port.inbound.GetLeadProjectsUseCase; +import com.gepardec.mega.hexagon.project.application.port.inbound.SetLeistungsnachweisEnabledUseCase; +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.shared.application.security.AuthenticatedActorContext; +import com.gepardec.mega.hexagon.shared.application.security.ForbiddenException; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.Role; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; +import io.restassured.http.ContentType; +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.*; + +@QuarkusTest +@TestSecurity(user = "test") +class ProjectResourceTest { + + + @InjectMock + GetLeadProjectsUseCase getLeadProjectsUseCase; + @InjectMock + SetLeistungsnachweisEnabledUseCase setLeistungsnachweisEnabledUseCase; + @InjectMock + AuthenticatedActorContext authenticatedActorContext; + + + private static final UserId LEAD_ID = UserId.of(UUID.randomUUID()); + private static final ProjectId PROJECT_ID = ProjectId.of(UUID.randomUUID()); + + @BeforeEach + void setUp() { + when(authenticatedActorContext.userId()).thenReturn(LEAD_ID); + } + + private void allowRoles(Role... roles) { + when(authenticatedActorContext.roles()).thenReturn(Set.of(roles)); + } + + @Test + void getLeadProjects_shouldReturnMappedProjects_whenUserIsLead() { + allowRoles(Role.PROJECT_LEAD); + Project project = new Project(PROJECT_ID,123,"X", LocalDate.now(),null,true, true, Set.of(LEAD_ID)); + when(getLeadProjectsUseCase.getLeadProjects(LEAD_ID)).thenReturn(List.of(project)); + + given() + .accept(ContentType.JSON) + .get("/projects") + .then() + .statusCode(HttpStatus.SC_OK) + .body("[0].id",is(PROJECT_ID.value().toString())) + .body("[0].name", is("X")); + + } + + @Test + void getLeadProjects_shouldReturnEmptyList_whenLeadUserHasNoProjects() { + allowRoles(Role.PROJECT_LEAD); + when(getLeadProjectsUseCase.getLeadProjects(LEAD_ID)).thenReturn(List.of()); + + given() + .accept(ContentType.JSON) + .get("/projects") + .then() + .statusCode(HttpStatus.SC_OK) + .body("size()", is(0)); + } + + @Test + void getLeadProjects_shouldRejectWithForbidden_whenUserIsNotLead() { + allowRoles(Role.EMPLOYEE); + + given() + .accept(ContentType.JSON) + .get("/projects") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + } + + @Test + void setLeistungsnachweisEnabled_shoudlToggleOwnProject() { + allowRoles(Role.PROJECT_LEAD); + var request = new LeistungsnachweisToggleRequestDto().enabled(false); + + given() + .accept(ContentType.JSON) + .body(request) + .contentType(ContentType.JSON) + .put("/projects/" + PROJECT_ID.value() + "/leistungsnachweis-enabled") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + verify(setLeistungsnachweisEnabledUseCase).setLeistungsnachweisEnabled(PROJECT_ID, LEAD_ID, false); + } + + @Test + void setLeistungsnachweisEnabled_shouldReturnForbidden_whenUserIsNotLead() { + allowRoles(Role.PROJECT_LEAD); + var request = new LeistungsnachweisToggleRequestDto().enabled(false); + doThrow(new ForbiddenException("user is not lead")) + .when(setLeistungsnachweisEnabledUseCase) + .setLeistungsnachweisEnabled(PROJECT_ID, LEAD_ID, false); + + given() + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .body(request) + .put("/projects/" + PROJECT_ID.value() + "/leistungsnachweis-enabled") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + + } + + @Test + void setLeistungsnachweisEnabled_shoudlReturnNotFound_whenProjectIsUnknown() { + allowRoles(Role.PROJECT_LEAD); + var request = new LeistungsnachweisToggleRequestDto().enabled(false); + doThrow(new IllegalArgumentException("Project not found")) + .when(setLeistungsnachweisEnabledUseCase) + .setLeistungsnachweisEnabled(PROJECT_ID, LEAD_ID, false); + + given() + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .body(request) + .put("/projects/" + PROJECT_ID.value() + "/leistungsnachweis-enabled") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectRepositoryAdapterTest.java b/src/test/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectRepositoryAdapterTest.java new file mode 100644 index 000000000..f008d56b3 --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/project/adapter/outbound/ProjectRepositoryAdapterTest.java @@ -0,0 +1,34 @@ +package com.gepardec.mega.hexagon.project.adapter.outbound; + +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import io.quarkus.test.TestTransaction; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@QuarkusTest +@TestTransaction +public class ProjectRepositoryAdapterTest { + + @Inject + ProjectRepositoryAdapter projectRepositoryAdapter; + + @Test + void saveAll_andFindAllByIds_shouldPersistAndLoadLeistungsnachweisEnabled() { + ProjectId id = ProjectId.generate(); + Project project = new Project(id, 88, "Test", LocalDate.now(), null, true, false, Set.of()); + + projectRepositoryAdapter.saveAll(List.of(project)); + + List loaded = projectRepositoryAdapter.findAllByIds(Set.of(id)); + assertThat(loaded).hasSize(1); + assertThat(loaded.getFirst().leistungsnachweisEnabled()).isFalse(); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/project/application/ProjectSettingsServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/project/application/ProjectSettingsServiceTest.java new file mode 100644 index 000000000..ad20088dc --- /dev/null +++ b/src/test/java/com/gepardec/mega/hexagon/project/application/ProjectSettingsServiceTest.java @@ -0,0 +1,110 @@ +package com.gepardec.mega.hexagon.project.application; + +import com.gepardec.mega.hexagon.project.domain.event.LeistungsnachweisDisabledEvent; +import com.gepardec.mega.hexagon.project.domain.model.Project; +import com.gepardec.mega.hexagon.project.domain.port.outbound.ProjectRepository; +import com.gepardec.mega.hexagon.shared.application.security.ForbiddenException; +import com.gepardec.mega.hexagon.shared.domain.model.ProjectId; +import com.gepardec.mega.hexagon.shared.domain.model.UserId; +import jakarta.enterprise.event.Event; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; + +public class ProjectSettingsServiceTest { + private ProjectRepository projectRepository; + private Event leistungsnachweisDisabledEvent; + private ProjectSettingsService projectSettingsService; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + projectRepository = mock(ProjectRepository.class); + leistungsnachweisDisabledEvent = mock(Event.class); + projectSettingsService = new ProjectSettingsService(projectRepository, leistungsnachweisDisabledEvent); + } + + @Test + void setLeistungsnachweisEnabled_shouldSaveUpdatedProject_whenUserIsLead() { + UserId leadId = UserId.of(UUID.randomUUID()); + Project project = new Project(ProjectId.generate(),1,"X", LocalDate.now(),null, true,true, Set.of(leadId)); + + when(projectRepository.findAllByIds(Set.of(project.id()))).thenReturn(List.of(project)); + projectSettingsService.setLeistungsnachweisEnabled(project.id(), leadId, false); + + verify(projectRepository).saveAll(argThat(projects -> { + Project savedProject = projects.getFirst(); + return !savedProject.leistungsnachweisEnabled(); + })); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(LeistungsnachweisDisabledEvent.class); + verify(leistungsnachweisDisabledEvent).fire(eventCaptor.capture()); + assertThat(eventCaptor.getValue().projectId()).isEqualTo(project.id()); + } + + @Test + void setLeistungsnachweisEnabled_shouldNotFireDisabledEvent_whenEnabling() { + UserId leadId = UserId.of(UUID.randomUUID()); + Project project = new Project(ProjectId.generate(),1,"X", LocalDate.now(),null, true,false, Set.of(leadId)); + + when(projectRepository.findAllByIds(Set.of(project.id()))).thenReturn(List.of(project)); + projectSettingsService.setLeistungsnachweisEnabled(project.id(), leadId, true); + + verify(leistungsnachweisDisabledEvent, never()).fire(any(LeistungsnachweisDisabledEvent.class)); + } + + @Test + void setLeistungsnachweisEnabled_shouldThrowForbidden_whenUserIsNotLead() { + UserId leadId = UserId.of(UUID.randomUUID()); + UserId otherLeadId = UserId.of(UUID.randomUUID()); + Project project = new Project(ProjectId.generate(),2,"Y", LocalDate.now(),null, true,true, Set.of(otherLeadId)); + + when(projectRepository.findAllByIds(Set.of(project.id()))).thenReturn(List.of(project)); + Throwable thrown = catchThrowable(() -> projectSettingsService.setLeistungsnachweisEnabled(project.id(), leadId, false)); + + assertThat(thrown) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("is not a lead"); + + verify(projectRepository, never()).saveAll(anyList()); + } + + @Test + void setLeistungsnachweisEnabled_shouldThrowIllegalArgument_whenProjectDoesNotExist() { + UserId leadId = UserId.of(UUID.randomUUID()); + Project project = new Project(ProjectId.generate(),3,"Z", LocalDate.now(),null, true,true, Set.of(leadId)); + + when(projectRepository.findAllByIds(Set.of(project.id()))).thenReturn(List.of()); + Throwable thrown = catchThrowable(() -> projectSettingsService.setLeistungsnachweisEnabled(project.id(), leadId, false)); + + assertThat(thrown) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Project not found:"); + + verify(projectRepository, never()).saveAll(anyList()); + } + + @Test + void getLeadProjects_shouldReturnProjectsForLead() { + UserId leadId = UserId.of(UUID.randomUUID()); + List expectedProjects = List.of( + new Project(ProjectId.generate(), 1, "Project A", LocalDate.now(), null, true, true, Set.of(leadId)), + new Project(ProjectId.generate(), 2, "Project B", LocalDate.now(), null, false, true, Set.of(leadId)) + ); + + when(projectRepository.findAllByLead(leadId)).thenReturn(expectedProjects); + List result = projectSettingsService.getLeadProjects(leadId); + + + assertThat(result).isEqualTo(expectedProjects); + verify(projectRepository).findAllByLead(leadId); + } +} diff --git a/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectLeadsServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectLeadsServiceTest.java index e97ad4e21..d4f287078 100644 --- a/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectLeadsServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectLeadsServiceTest.java @@ -87,7 +87,7 @@ void sync_skipsUnknownUsername() { void sync_replacesFullLeadsSet() { UserId oldLead = UserId.of(UUID.randomUUID()); UserId newLead = UserId.of(UUID.randomUUID()); - Project project = new Project(ProjectId.generate(), 3, "P3", LocalDate.now(), null, false, Set.of(oldLead)); + Project project = new Project(ProjectId.generate(), 3, "P3", LocalDate.now(), null, false, true, Set.of(oldLead)); when(projectRepository.findAll()).thenReturn(List.of(project)); when(zepProjectPort.fetchLeadUsernames(3)).thenReturn(List.of("newguy")); @@ -105,7 +105,7 @@ void sync_replacesFullLeadsSet() { @Test void sync_doesNotPersistWhenLeadsAndRolesAreAlreadyUpToDate() { UserId leadId = UserId.of(UUID.randomUUID()); - Project project = new Project(ProjectId.generate(), 6, "P6", LocalDate.now(), null, false, Set.of(leadId)); + Project project = new Project(ProjectId.generate(), 6, "P6", LocalDate.now(), null, false, true, Set.of(leadId)); when(projectRepository.findAll()).thenReturn(List.of(project)); when(zepProjectPort.fetchLeadUsernames(6)).thenReturn(List.of("stable")); diff --git a/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectsServiceTest.java b/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectsServiceTest.java index f9d1d1cd4..b77c19b37 100644 --- a/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectsServiceTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/project/application/SyncProjectsServiceTest.java @@ -61,7 +61,7 @@ void sync_createsNewProjectForUnknownZepId() { void sync_updatesExistingProjectByZepId() { ProjectId existingId = ProjectId.generate(); Project existing = new Project(existingId, 42, "Old Name", - LocalDate.of(2023, 1, 1), null, false, Set.of()); + LocalDate.of(2023, 1, 1), null, false, true, Set.of()); when(zepProjectPort.fetchAll()).thenReturn(List.of(profile(42, "New Name"))); when(projectRepository.findAll()).thenReturn(List.of(existing)); @@ -80,7 +80,7 @@ void sync_updatesExistingProjectByZepId() { @Test void sync_preservesProjectIdOnUpdate() { ProjectId existingId = ProjectId.of(UUID.fromString(Instancio.gen().text().uuid().get())); - Project existing = new Project(existingId, 7, "X", LocalDate.now(), null, false, Set.of()); + Project existing = new Project(existingId, 7, "X", LocalDate.now(), null, false, true, Set.of()); when(zepProjectPort.fetchAll()).thenReturn(List.of(profile(7, "X Updated"))); when(projectRepository.findAll()).thenReturn(List.of(existing)); @@ -97,7 +97,7 @@ void sync_preservesProjectIdOnUpdate() { void sync_doesNotModifyLeads() { UserId leadId = UserId.of(UUID.randomUUID()); Project existing = new Project(ProjectId.generate(), 5, "Y", - LocalDate.now(), null, false, Set.of(leadId)); + LocalDate.now(), null, false, true, Set.of(leadId)); when(zepProjectPort.fetchAll()).thenReturn(List.of(profile(5, "Y Updated"))); when(projectRepository.findAll()).thenReturn(List.of(existing)); @@ -147,8 +147,8 @@ void sync_result_countsCreatedProjects() { @Test void sync_result_countsUpdatedProjects() { - Project existing1 = new Project(ProjectId.generate(), 1, "A", LocalDate.now(), null, false, Set.of()); - Project existing2 = new Project(ProjectId.generate(), 2, "B", LocalDate.now(), null, false, Set.of()); + Project existing1 = new Project(ProjectId.generate(), 1, "A", LocalDate.now(), null, false, true, Set.of()); + Project existing2 = new Project(ProjectId.generate(), 2, "B", LocalDate.now(), null, false, true, Set.of()); when(zepProjectPort.fetchAll()).thenReturn(List.of(profile(1, "A Updated"), profile(2, "B Updated"))); when(projectRepository.findAll()).thenReturn(List.of(existing1, existing2)); @@ -162,7 +162,7 @@ void sync_result_countsUpdatedProjects() { @Test void sync_result_countsMixedOperations() { - Project existing = new Project(ProjectId.generate(), 1, "A", LocalDate.now(), null, false, Set.of()); + Project existing = new Project(ProjectId.generate(), 1, "A", LocalDate.now(), null, false, true, Set.of()); when(zepProjectPort.fetchAll()).thenReturn(List.of(profile(1, "A Updated"), profile(2, "New"))); when(projectRepository.findAll()).thenReturn(List.of(existing)); @@ -183,6 +183,7 @@ void sync_doesNotCountOrPersistUnchangedProjects() { LocalDate.of(2024, 1, 1), LocalDate.of(2024, 12, 31), false, + true, Set.of() ); diff --git a/src/test/java/com/gepardec/mega/hexagon/project/domain/model/ProjectTest.java b/src/test/java/com/gepardec/mega/hexagon/project/domain/model/ProjectTest.java index 351e1d276..566d6eca1 100644 --- a/src/test/java/com/gepardec/mega/hexagon/project/domain/model/ProjectTest.java +++ b/src/test/java/com/gepardec/mega/hexagon/project/domain/model/ProjectTest.java @@ -49,7 +49,7 @@ void constructor_setsAllFields() { LocalDate start = LocalDate.of(2023, 6, 1); LocalDate end = LocalDate.of(2024, 6, 1); - Project project = new Project(id, 99, "Beta", start, end, false, Set.of(leadId)); + Project project = new Project(id, 99, "Beta", start, end, false, true, Set.of(leadId)); assertThat(project.id()).isEqualTo(id); assertThat(project.zepId()).isEqualTo(99); @@ -99,7 +99,7 @@ void withLeads_replacesLeadsSet() { void withLeads_replacesExistingLeads() { UserId oldLead = UserId.of(UUID.randomUUID()); Project project = new Project(ProjectId.generate(), 5, "Delta", - LocalDate.now(), null, false, Set.of(oldLead)); + LocalDate.now(), null, false, true, Set.of(oldLead)); UserId newLead = UserId.of(UUID.randomUUID()); Project updatedProject = project.withLeads(Set.of(newLead)); @@ -120,9 +120,9 @@ void create_setBillableFromProfile() { @Test void constructor_setBillableFromParameter() { Project billable = new Project(ProjectId.generate(), 1, "Billable", - LocalDate.now(), null, true, Set.of()); + LocalDate.now(), null, true, true, Set.of()); Project notBillable = new Project(ProjectId.generate(), 2, "Internal", - LocalDate.now(), null, false, Set.of()); + LocalDate.now(), null, false, true, Set.of()); assertThat(billable.billable()).isTrue(); assertThat(notBillable.billable()).isFalse(); @@ -137,6 +137,48 @@ void withSyncedZepData_updatesBillable() { assertThat(synchronizedProject.billable()).isTrue(); } + @Test + void create_leistungsnachweisEnabledDefaultsToTrue() { + Project project = Project.create(ProjectId.generate(), profile(1, "X")); + assertThat(project.leistungsnachweisEnabled()).isTrue(); + } + + @Test + void constructor_canSetLeistungsnachweisEnabled() { + Project enabled = new Project(ProjectId.generate(), 1, "X", LocalDate.now(), null, false, true, Set.of()); + Project disabled = new Project(ProjectId.generate(), 1, "Y", LocalDate.now(), null, false, false, Set.of()); + + assertThat(enabled.leistungsnachweisEnabled()).isTrue(); + assertThat(disabled.leistungsnachweisEnabled()).isFalse(); + } + + @Test + void withSyncedZepData_preservesLeistungsnachweisEnabled() { + Project project = new Project(ProjectId.generate(), 1, "X", LocalDate.now(), null, false, false, Set.of()); + Project synced = project.withSyncedZepData(new ZepProjectProfile(1, "Y", LocalDate.now(), null, false)); + + assertThat(synced.leistungsnachweisEnabled()).isFalse(); + } + + @Test + void withLeads_preservesLeistungsnachweisEnabled() { + Project project = new Project(ProjectId.generate(),1,"X", LocalDate.now(),null, false,false, Set.of()); + + Project updated = project.withLeads(Set.of(UserId.of(UUID.randomUUID()))); + assertThat(updated.leistungsnachweisEnabled()).isFalse(); + } + + @Test + void withLeistungsnachweisEnabled_returnsNewInstanceWithToggledFlag() { + Project project = Project.create(ProjectId.generate(), profile(1, "X")); + + Project disabled = project.withLeistungsnachweisEnabled(false); + Project reEnabled = disabled.withLeistungsnachweisEnabled(true); + + assertThat(disabled.leistungsnachweisEnabled()).isFalse(); + assertThat(reEnabled.leistungsnachweisEnabled()).isTrue(); + assertThat(project.leistungsnachweisEnabled()).isTrue(); + } @Test void leads_returnsDefensiveCopy() { @@ -156,6 +198,7 @@ void isActiveIn_shouldTreatAnyOverlapWithMonthAsActive() { LocalDate.of(2024, 3, 15), LocalDate.of(2024, 4, 15), false, + true, Set.of() );