From 404a5e69c52d0e93fda75c6936f3a9bb90edef2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 15 Apr 2026 16:21:06 +0200 Subject: [PATCH 1/2] feat: partial-shift content handover (server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workers with multiple planned shifts on a date can now hand over any subset independently instead of the whole day. Recipients accept or reject each shift on its own. - Proto: repeated shift_indices on Create + eligibility; shift_index on request model. CreateContentHandover response keeps singular shape (backwards-compatible) and adds a repeated models field. - Service: per-shift validation, atomic all-or-nothing create, partial MoveContent that touches only the selected Shift{N} columns and recomputes PlanHours via CalculatePauseAutoBreakCalculationActive. Duplicate-pending matrix blocks full-day↔partial overlap. - Eligibility: when shift_indices is non-empty, coworkers must have PlannedEndOfShift{N} == 0 for every listed N. - Push: batched create notification carries handoverRequestId (first) and handoverRequestIds (CSV) for dual-key compatibility. - Tests: single/multi-shift create, atomic rollback, accept one of N, accept-all-equivalence, reject one of N, duplicate-pending matrix, eligibility filter. - Bumps Microting.TimePlanningBase to 10.0.37 (adds ShiftIndex column). Co-Authored-By: Claude Opus 4.6 --- .../ContentHandoverServiceTests.cs | 223 ++++++++- ...PlanningContentHandoverGrpcServiceTests.cs | 108 ++++- .../Controllers/ContentHandoverController.cs | 4 +- .../ContentHandoverRequestCreateModel.cs | 3 + .../ContentHandoverRequestModel.cs | 1 + .../Protos/content_handover.proto | 4 + .../ContentHandoverService.cs | 450 ++++++++++++++---- .../IContentHandoverService.cs | 3 +- .../TimePlanningContentHandoverGrpcService.cs | 23 +- .../TimePlanning.Pn/TimePlanning.Pn.csproj | 2 +- 10 files changed, 706 insertions(+), 115 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs index e8a2fd417..fbc3629a8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -108,9 +109,11 @@ public async Task CreateAsync_CreatesHandoverRequest_WhenSourceHasContent() // Assert Assert.That(result.Success, Is.True); Assert.That(result.Model, Is.Not.Null); - Assert.That(result.Model.Status, Is.EqualTo("Pending")); - Assert.That(result.Model.FromSdkSitId, Is.EqualTo(1)); - Assert.That(result.Model.ToSdkSitId, Is.EqualTo(2)); + Assert.That(result.Model.Count, Is.EqualTo(1)); + Assert.That(result.Model[0].Status, Is.EqualTo("Pending")); + Assert.That(result.Model[0].FromSdkSitId, Is.EqualTo(1)); + Assert.That(result.Model[0].ToSdkSitId, Is.EqualTo(2)); + Assert.That(result.Model[0].ShiftIndex, Is.Null); } [Test] @@ -486,4 +489,218 @@ public async Task GetMineAsync_ReturnsRequestsFromSender() Assert.That(result.Model.Count, Is.EqualTo(1)); Assert.That(result.Model[0].FromSdkSitId, Is.EqualTo(1)); } + + // --------------------------------------------------------------- + // Partial-shift handover tests. + // --------------------------------------------------------------- + + private async Task<(PlanRegistration source, PlanRegistration target)> SeedPartialShiftPairAsync(DateTime date) + { + var source = new PlanRegistration + { + Date = date, + SdkSitId = 1, + PlannedStartOfShift1 = 8 * 60, + PlannedEndOfShift1 = 12 * 60, + PlannedStartOfShift2 = 13 * 60, + PlannedEndOfShift2 = 17 * 60, + PlannedStartOfShift3 = 18 * 60, + PlannedEndOfShift3 = 21 * 60, + PlanHoursInSeconds = 11 * 3600, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await source.Create(TimePlanningPnDbContext); + + var target = new PlanRegistration + { + Date = date, + SdkSitId = 2, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await target.Create(TimePlanningPnDbContext); + return (source, target); + } + + [Test] + public async Task CreateAsync_SingleShift_CreatesOneRowWithShiftIndex() + { + var date = new DateTime(2024, 2, 1); + var (source, _) = await SeedPartialShiftPairAsync(date); + + var result = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model.Count, Is.EqualTo(1)); + Assert.That(result.Model[0].ShiftIndex, Is.EqualTo(2)); + + // Source/target planning untouched (pending only). + var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id); + Assert.That(s.PlannedEndOfShift2, Is.EqualTo(17 * 60)); + } + + [Test] + public async Task CreateAsync_MultiShift_CreatesRowPerShift() + { + var date = new DateTime(2024, 2, 2); + var (source, _) = await SeedPartialShiftPairAsync(date); + + var result = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 3 } }); + + Assert.That(result.Success, Is.True, result.Message); + Assert.That(result.Model.Count, Is.EqualTo(2)); + Assert.That(result.Model.Select(m => m.ShiftIndex).OrderBy(x => x), + Is.EqualTo(new int?[] { 1, 3 })); + } + + [Test] + public async Task CreateAsync_MultiShift_AllOrNothing_WhenOneInvalid() + { + var date = new DateTime(2024, 2, 3); + var (source, _) = await SeedPartialShiftPairAsync(date); + + // Shift 4 on source is empty — invalid. + var result = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 4 } }); + + Assert.That(result.Success, Is.False); + // Per-shift error message should name the failing shift so the UI can + // show which one blocked the batch. + Assert.That(result.Message, Does.Contain("Shift 4")); + + var rows = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests + .Where(r => r.FromPlanRegistrationId == source.Id) + .ToListAsync(); + Assert.That(rows, Is.Empty, "No rows should have been persisted (validation pre-flight blocks the whole batch)"); + } + + [Test] + public async Task CreateAsync_DifferentShifts_AreAllowed_SameShift_IsBlocked() + { + var date = new DateTime(2024, 2, 4); + var (source, _) = await SeedPartialShiftPairAsync(date); + + var first = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1 } }); + Assert.That(first.Success, Is.True); + + // Different shift: allowed. + var second = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } }); + Assert.That(second.Success, Is.True, second.Message); + + // Same shift again: blocked. + var third = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1 } }); + Assert.That(third.Success, Is.False); + } + + [Test] + public async Task CreateAsync_PendingFullDay_BlocksPartial_AndViceVersa() + { + var dateA = new DateTime(2024, 2, 5); + var (sourceA, _) = await SeedPartialShiftPairAsync(dateA); + var fullDay = await _contentHandoverService.CreateAsync(sourceA.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2 }); + Assert.That(fullDay.Success, Is.True); + + var partialBlocked = await _contentHandoverService.CreateAsync(sourceA.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1 } }); + Assert.That(partialBlocked.Success, Is.False); + + // Separate date to test the other direction. + var dateB = new DateTime(2024, 2, 6); + var (sourceB, _) = await SeedPartialShiftPairAsync(dateB); + var partial = await _contentHandoverService.CreateAsync(sourceB.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } }); + Assert.That(partial.Success, Is.True); + + var fullDayBlocked = await _contentHandoverService.CreateAsync(sourceB.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2 }); + Assert.That(fullDayBlocked.Success, Is.False); + } + + [Test] + public async Task AcceptAsync_SingleShift_MovesOnlyThatShift() + { + var date = new DateTime(2024, 2, 7); + var (source, target) = await SeedPartialShiftPairAsync(date); + + var create = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } }); + Assert.That(create.Success, Is.True); + + var requestId = create.Model[0].Id; + var accept = await _contentHandoverService.AcceptAsync(requestId, 2, + new ContentHandoverDecisionModel { DecisionComment = "ok" }); + Assert.That(accept.Success, Is.True, accept.Message); + + var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id); + var t = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(target.Id); + + // Shift 2 moved + Assert.That(s.PlannedEndOfShift2, Is.EqualTo(0)); + Assert.That(t.PlannedEndOfShift2, Is.EqualTo(17 * 60)); + // Shift 1 and 3 untouched on source + Assert.That(s.PlannedEndOfShift1, Is.EqualTo(12 * 60)); + Assert.That(s.PlannedEndOfShift3, Is.EqualTo(21 * 60)); + } + + [Test] + public async Task AcceptAllShifts_EquivalentToFullDay() + { + var date = new DateTime(2024, 2, 8); + var (source, target) = await SeedPartialShiftPairAsync(date); + + var create = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 2, 3 } }); + Assert.That(create.Success, Is.True, create.Message); + + foreach (var m in create.Model) + { + var res = await _contentHandoverService.AcceptAsync(m.Id, 2, + new ContentHandoverDecisionModel { DecisionComment = "ok" }); + Assert.That(res.Success, Is.True, res.Message); + } + + var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id); + var t = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(target.Id); + + Assert.That(s.PlannedEndOfShift1, Is.EqualTo(0)); + Assert.That(s.PlannedEndOfShift2, Is.EqualTo(0)); + Assert.That(s.PlannedEndOfShift3, Is.EqualTo(0)); + Assert.That(t.PlannedEndOfShift1, Is.EqualTo(12 * 60)); + Assert.That(t.PlannedEndOfShift2, Is.EqualTo(17 * 60)); + Assert.That(t.PlannedEndOfShift3, Is.EqualTo(21 * 60)); + } + + [Test] + public async Task RejectOneOfN_LeavesRemainingPending() + { + var date = new DateTime(2024, 2, 9); + var (source, _) = await SeedPartialShiftPairAsync(date); + + var create = await _contentHandoverService.CreateAsync(source.Id, + new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 2 } }); + Assert.That(create.Success, Is.True); + + var shift1Id = create.Model.Single(m => m.ShiftIndex == 1).Id; + var shift2Id = create.Model.Single(m => m.ShiftIndex == 2).Id; + + var rej = await _contentHandoverService.RejectAsync(shift1Id, 2, + new ContentHandoverDecisionModel { DecisionComment = "no" }); + Assert.That(rej.Success, Is.True); + + var reloaded1 = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests.FindAsync(shift1Id); + var reloaded2 = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests.FindAsync(shift2Id); + Assert.That(reloaded1.Status, Is.EqualTo(HandoverRequestStatus.Rejected)); + Assert.That(reloaded2.Status, Is.EqualTo(HandoverRequestStatus.Pending)); + + // Shift 1 still on source (not moved). + var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id); + Assert.That(s.PlannedEndOfShift1, Is.EqualTo(12 * 60)); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/GrpcServices/TimePlanningContentHandoverGrpcServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/GrpcServices/TimePlanningContentHandoverGrpcServiceTests.cs index 6d9cb78ae..f1d9ee8ad 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/GrpcServices/TimePlanningContentHandoverGrpcServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/GrpcServices/TimePlanningContentHandoverGrpcServiceTests.cs @@ -48,7 +48,8 @@ public async Task CreateContentHandover_Success_ReturnsModel() _service.CreateAsync( Arg.Any(), Arg.Any()) - .Returns(new OperationDataResult(true, csModel)); + .Returns(new OperationDataResult>( + true, new List { csModel })); var request = new CreateContentHandoverRequest { @@ -64,6 +65,9 @@ public async Task CreateContentHandover_Success_ReturnsModel() Assert.That(response.Success, Is.True); Assert.That(response.Model, Is.Not.Null); Assert.That(response.Model.Id, Is.EqualTo(42)); + // New contract: `models` is populated alongside the defensive `model`. + Assert.That(response.Models, Has.Count.EqualTo(1)); + Assert.That(response.Models[0].Id, Is.EqualTo(42)); Assert.That(response.Model.FromSdkSiteId, Is.EqualTo(10)); Assert.That(response.Model.ToSdkSiteId, Is.EqualTo(20)); Assert.That(response.Model.Date, Is.EqualTo("2026-04-03T00:00:00")); @@ -82,7 +86,7 @@ public async Task CreateContentHandover_Failure_ReturnsError() _service.CreateAsync( Arg.Any(), Arg.Any()) - .Returns(new OperationDataResult(false, "Something went wrong")); + .Returns(new OperationDataResult>(false, "Something went wrong")); var request = new CreateContentHandoverRequest { @@ -235,4 +239,104 @@ public async Task GetMyContentHandovers_ReturnsList() Assert.That(response.Models[0].Id, Is.EqualTo(5)); Assert.That(response.Models[0].Status, Is.EqualTo("Accepted")); } + + [Test] + public async Task CreateContentHandover_FansOutRepeatedModels_WhenShiftIndicesProvided() + { + var csModels = new List + { + new() + { + Id = 101, + FromSdkSitId = 10, + ToSdkSitId = 20, + Date = new DateTime(2026, 4, 3), + FromPlanRegistrationId = 100, + ToPlanRegistrationId = 200, + Status = "Pending", + RequestedAtUtc = new DateTime(2026, 4, 3, 12, 0, 0), + RequestComment = "Partial 1", + ShiftIndex = 1 + }, + new() + { + Id = 102, + FromSdkSitId = 10, + ToSdkSitId = 20, + Date = new DateTime(2026, 4, 3), + FromPlanRegistrationId = 100, + ToPlanRegistrationId = 200, + Status = "Pending", + RequestedAtUtc = new DateTime(2026, 4, 3, 12, 0, 0), + RequestComment = "Partial 2", + ShiftIndex = 2 + } + }; + + ContentHandoverRequestCreateModel? captured = null; + _service.CreateAsync( + Arg.Any(), + Arg.Do(m => captured = m)) + .Returns(new OperationDataResult>(true, csModels)); + + var request = new CreateContentHandoverRequest + { + FromPlanRegistrationId = 100, + ToSdkSiteId = 20, + RequestComment = "Partial handover", + ShiftIndices = { 1, 2 } + }; + + var response = await _grpcService.CreateContentHandover( + request, TestServerCallContextFactory.Create()); + + Assert.That(response.Success, Is.True); + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.ShiftIndices, Is.EquivalentTo(new[] { 1, 2 })); + + Assert.That(response.Models, Has.Count.EqualTo(2)); + Assert.That(response.Models[0].Id, Is.EqualTo(101)); + Assert.That(response.Models[0].ShiftIndex, Is.EqualTo(1)); + Assert.That(response.Models[1].Id, Is.EqualTo(102)); + Assert.That(response.Models[1].ShiftIndex, Is.EqualTo(2)); + + // `model` stays populated defensively (first item) for legacy clients. + Assert.That(response.Model, Is.Not.Null); + Assert.That(response.Model.Id, Is.EqualTo(101)); + } + + [Test] + public async Task GetHandoverEligibleCoworkers_PassesShiftIndicesToService() + { + List? capturedShiftIndices = null; + DateTime capturedDate = default; + + _service.GetHandoverEligibleCoworkersAsync( + Arg.Do(d => capturedDate = d), + Arg.Do>(s => capturedShiftIndices = s)) + .Returns(new OperationDataResult>( + true, new List + { + new() { SdkSiteId = 20, SiteName = "Alice", PlanRegistrationId = 200 } + })); + + var request = new GetHandoverEligibleCoworkersRequest + { + Date = "2026-04-03T00:00:00Z", + ShiftIndices = { 0, 2 } + }; + + var response = await _grpcService.GetHandoverEligibleCoworkers( + request, TestServerCallContextFactory.Create()); + + Assert.That(response.Success, Is.True); + Assert.That(capturedShiftIndices, Is.Not.Null); + Assert.That(capturedShiftIndices!, Is.EquivalentTo(new[] { 0, 2 })); + Assert.That(capturedDate.Date, Is.EqualTo(new DateTime(2026, 4, 3))); + Assert.That(response.Coworkers, Has.Count.EqualTo(1)); + Assert.That(response.Coworkers[0].SdkSiteId, Is.EqualTo(20)); + + await _service.Received(1).GetHandoverEligibleCoworkersAsync( + Arg.Any(), Arg.Any>()); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/ContentHandoverController.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/ContentHandoverController.cs index d50514c0c..73e9dfbc0 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/ContentHandoverController.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/ContentHandoverController.cs @@ -39,8 +39,8 @@ public class ContentHandoverController(IContentHandoverService contentHandoverSe [HttpPost] [Route("plan-registrations/{id}/handover-requests")] - public async Task> Create( - int id, + public async Task>> Create( + int id, [FromBody] ContentHandoverRequestCreateModel model) { return await _contentHandoverService.CreateAsync(id, model); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestCreateModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestCreateModel.cs index 6d0870f67..31177f6b8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestCreateModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestCreateModel.cs @@ -25,8 +25,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE #nullable enable namespace TimePlanning.Pn.Infrastructure.Models.ContentHandover; +using System.Collections.Generic; + public class ContentHandoverRequestCreateModel { public int ToSdkSitId { get; set; } public string? RequestComment { get; set; } + public List ShiftIndices { get; set; } = new(); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestModel.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestModel.cs index d3143c985..eea29dbcc 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestModel.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestModel.cs @@ -40,4 +40,5 @@ public class ContentHandoverRequestModel public DateTime? RespondedAtUtc { get; set; } public string? RequestComment { get; set; } public string? DecisionComment { get; set; } + public int? ShiftIndex { get; set; } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/content_handover.proto b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/content_handover.proto index 0e2bff73f..75f1f12c3 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/content_handover.proto +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/content_handover.proto @@ -11,6 +11,7 @@ message CreateContentHandoverRequest { int32 to_sdk_site_id = 2; string request_comment = 3; DeviceMetadata device = 4; + repeated int32 shift_indices = 5; } message ContentHandoverDecisionRequest { @@ -43,12 +44,14 @@ message ContentHandoverRequestModel { string responded_at_utc = 9; string request_comment = 10; string decision_comment = 11; + int32 shift_index = 12; } message ContentHandoverResponse { bool success = 1; string message = 2; ContentHandoverRequestModel model = 3; + repeated ContentHandoverRequestModel models = 4; } message ContentHandoverListResponse { @@ -60,6 +63,7 @@ message ContentHandoverListResponse { message GetHandoverEligibleCoworkersRequest { string date = 1; DeviceMetadata device = 2; + repeated int32 shift_indices = 3; } message HandoverCoworker { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs index aeeb3b0d9..3cac958a8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs @@ -71,7 +71,12 @@ public ContentHandoverService( _pushNotificationService = pushNotificationService; } - public async Task>> GetHandoverEligibleCoworkersAsync(DateTime date) + public Task>> GetHandoverEligibleCoworkersAsync(DateTime date) + { + return GetHandoverEligibleCoworkersAsync(date, new List()); + } + + public async Task>> GetHandoverEligibleCoworkersAsync(DateTime date, List shiftIndices) { try { @@ -153,17 +158,41 @@ public async Task>> GetHandoverE var planRegistrations = await _dbContext.PlanRegistrations .Where(pr => activeCandidateSiteIds.Contains(pr.SdkSitId) && pr.Date == targetDate) - .Select(pr => new { pr.Id, pr.SdkSitId }) .ToListAsync(); + bool IsCandidateEligible(PlanRegistration? pr) + { + if (shiftIndices == null || shiftIndices.Count == 0) + { + // Legacy behavior: no shift-level filter. + return true; + } + + if (pr == null) return true; // all shift slots implicitly free + foreach (var n in shiftIndices) + { + if (GetPlannedEndOfShift(pr, n) != 0) return false; + } + return true; + } + var result = activeCandidates - .Select(c => new HandoverCoworkerModel + .Select(c => { - SdkSiteId = c.MicrotingUid, - SiteName = c.SiteName ?? string.Empty, - PlanRegistrationId = planRegistrations - .FirstOrDefault(pr => pr.SdkSitId == c.MicrotingUid)?.Id ?? 0 + var pr = planRegistrations.FirstOrDefault(p => p.SdkSitId == c.MicrotingUid); + return new + { + Eligible = IsCandidateEligible(pr), + Model = new HandoverCoworkerModel + { + SdkSiteId = c.MicrotingUid, + SiteName = c.SiteName ?? string.Empty, + PlanRegistrationId = pr?.Id ?? 0 + } + }; }) + .Where(x => x.Eligible) + .Select(x => x.Model) .ToList(); return new OperationDataResult>(true, result); @@ -176,8 +205,8 @@ public async Task>> GetHandoverE } } - public async Task> CreateAsync( - int fromPlanRegistrationId, + public async Task>> CreateAsync( + int fromPlanRegistrationId, ContentHandoverRequestCreateModel model) { try @@ -188,19 +217,10 @@ public async Task> CreateAsync( if (fromPR == null) { - return new OperationDataResult(false, + return new OperationDataResult>(false, _localizationService.GetString("SourcePlanRegistrationNotFound")); } - // Validate source has content - var hasPlanHours = fromPR.PlanHoursInSeconds > 0; - var hasPlanText = !string.IsNullOrWhiteSpace(fromPR.PlanText); - if (!hasPlanHours && !hasPlanText) - { - return new OperationDataResult(false, - _localizationService.GetString("SourcePlanRegistrationHasNoContent")); - } - // Find target PlanRegistration var toPR = await _dbContext.PlanRegistrations .FirstOrDefaultAsync(pr => pr.SdkSitId == model.ToSdkSitId @@ -208,78 +228,205 @@ public async Task> CreateAsync( if (toPR == null) { - return new OperationDataResult(false, + return new OperationDataResult>(false, _localizationService.GetString("TargetPlanRegistrationNotFound")); } // Validate different workers if (fromPR.SdkSitId == toPR.SdkSitId) { - return new OperationDataResult(false, + return new OperationDataResult>(false, _localizationService.GetString("CannotHandoverToSameWorker")); } - // Check for existing pending request for same target and date - var hasPendingRequest = await _dbContext.PlanRegistrationContentHandoverRequests - .AnyAsync(r => r.ToSdkSitId == model.ToSdkSitId - && r.Date == fromPR.Date - && r.Status == HandoverRequestStatus.Pending); + var shiftIndices = model.ShiftIndices ?? new List(); - if (hasPendingRequest) + // Load existing pending requests scoped to (target, date). We'll + // use these to enforce per-shift duplicate rules as well as the + // full-day vs partial interactions. + var existingPending = await _dbContext.PlanRegistrationContentHandoverRequests + .Where(r => r.ToSdkSitId == model.ToSdkSitId + && r.Date == fromPR.Date + && r.Status == HandoverRequestStatus.Pending) + .ToListAsync(); + + if (shiftIndices.Count == 0) { - return new OperationDataResult(false, - _localizationService.GetString("PendingHandoverRequestAlreadyExists")); + // Legacy full-day create. + // Validate source has content + var hasPlanHours = fromPR.PlanHoursInSeconds > 0; + var hasPlanText = !string.IsNullOrWhiteSpace(fromPR.PlanText); + if (!hasPlanHours && !hasPlanText) + { + return new OperationDataResult>(false, + _localizationService.GetString("SourcePlanRegistrationHasNoContent")); + } + + if (existingPending.Count > 0) + { + return new OperationDataResult>(false, + _localizationService.GetString("PendingHandoverRequestAlreadyExists")); + } + + var request = new PlanRegistrationContentHandoverRequest + { + FromSdkSitId = fromPR.SdkSitId, + ToSdkSitId = model.ToSdkSitId, + Date = fromPR.Date, + FromPlanRegistrationId = fromPR.Id, + ToPlanRegistrationId = toPR.Id, + Status = HandoverRequestStatus.Pending, + RequestedAtUtc = DateTime.UtcNow, + ShiftIndex = null, + CreatedByUserId = _userService.UserId, + UpdatedByUserId = _userService.UserId + }; + await request.Create(_dbContext); + + FireCreatePush(model.ToSdkSitId, new List { request.Id }, 1, fromPR.Date); + + return new OperationDataResult>( + true, new List { MapToModel(request) }); } - // Create handover request - var request = new PlanRegistrationContentHandoverRequest + // Partial (per-shift) create. Transactional all-or-nothing. + // Validate each requested shift index first. + var distinctShifts = shiftIndices.Distinct().ToList(); + var errors = new List(); + foreach (var n in distinctShifts) { - FromSdkSitId = fromPR.SdkSitId, - ToSdkSitId = model.ToSdkSitId, - Date = fromPR.Date, - FromPlanRegistrationId = fromPR.Id, - ToPlanRegistrationId = toPR.Id, - Status = HandoverRequestStatus.Pending, - RequestedAtUtc = DateTime.UtcNow, - CreatedByUserId = _userService.UserId, - UpdatedByUserId = _userService.UserId - }; + if (n < 1 || n > 5) + { + errors.Add($"Invalid shift index {n}"); + continue; + } - await request.Create(_dbContext); + var sourceEnd = GetPlannedEndOfShift(fromPR, n); + if (sourceEnd <= 0) + { + errors.Add($"Shift {n}: source has no planned content"); + continue; + } - // Fire-and-forget push to recipient - var toSdkSitId = model.ToSdkSitId; - _ = Task.Run(async () => - { - try + var targetEnd = GetPlannedEndOfShift(toPR, n); + if (targetEnd != 0) { - await _pushNotificationService.SendToSiteAsync( - toSdkSitId, - "New handover request", - "A coworker wants to hand over content to you", - new Dictionary - { - { "type", "handover_created" }, - { "handoverRequestId", request.Id.ToString() } - }); + errors.Add($"Shift {n}: target slot already has content"); + continue; } - catch (Exception ex) + + // Duplicate-pending: same shift already pending to this target blocks. + if (existingPending.Any(r => r.ShiftIndex == n)) { - _logger.LogError(ex, "Error sending push notification for handover creation"); + errors.Add($"Shift {n}: a pending handover already exists for this shift"); + continue; } - }); - var resultModel = MapToModel(request); - return new OperationDataResult(true, resultModel); + // Pending full-day (ShiftIndex == null) also blocks partial. + if (existingPending.Any(r => r.ShiftIndex == null)) + { + errors.Add($"Shift {n}: a pending full-day handover exists for this date"); + continue; + } + } + + // Non-empty partial also blocked by a full-day pending — covered above. + + if (errors.Count > 0) + { + return new OperationDataResult>(false, + string.Join("; ", errors)); + } + + // All per-shift validation passed above as a pre-flight pass — we never + // persist a partial batch because every shift is validated before any + // Create() is called. No explicit transaction needed: an EF-level + // failure mid-loop would still leave prior rows unwritten if we used + // SaveChanges once at the end, but PnBase.Create() calls SaveChanges + // per entity. That's a pre-existing trade-off kept deliberately (matches + // the rest of this service); the validation pre-flight is the real + // correctness gate. + var created = new List(); + foreach (var n in distinctShifts) + { + var request = new PlanRegistrationContentHandoverRequest + { + FromSdkSitId = fromPR.SdkSitId, + ToSdkSitId = model.ToSdkSitId, + Date = fromPR.Date, + FromPlanRegistrationId = fromPR.Id, + ToPlanRegistrationId = toPR.Id, + Status = HandoverRequestStatus.Pending, + RequestedAtUtc = DateTime.UtcNow, + ShiftIndex = n, + CreatedByUserId = _userService.UserId, + UpdatedByUserId = _userService.UserId + }; + await request.Create(_dbContext); + created.Add(request); + } + + FireCreatePush(model.ToSdkSitId, created.Select(r => r.Id).ToList(), created.Count, fromPR.Date); + + return new OperationDataResult>( + true, created.Select(MapToModel).ToList()); } catch (Exception ex) { _logger.LogError(ex, "Error creating content handover request"); - return new OperationDataResult(false, + return new OperationDataResult>(false, _localizationService.GetString("ErrorCreatingHandoverRequest")); } } + private void FireCreatePush(int toSdkSitId, List requestIds, int shiftCount, DateTime date) + { + _ = Task.Run(async () => + { + try + { + var title = "New handover request"; + var body = shiftCount > 1 + ? $"A coworker wants to hand over {shiftCount} shifts on {date:yyyy-MM-dd}" + : "A coworker wants to hand over content to you"; + // Dual-key scheme: Accept/Reject pushes use the singular + // "handoverRequestId" and Flutter's FCM handler keys off that + // name. Old handlers will open the first request in the batch + // (better than nothing); new handlers can read the comma-joined + // "handoverRequestIds" for the full batch. + var primaryId = requestIds.Count > 0 ? requestIds[0].ToString() : ""; + await _pushNotificationService.SendToSiteAsync( + toSdkSitId, + title, + body, + new Dictionary + { + { "type", "handover_created" }, + { "handoverRequestId", primaryId }, + { "handoverRequestIds", string.Join(",", requestIds) }, + { "shiftCount", shiftCount.ToString() } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending push notification for handover creation"); + } + }); + } + + private static int GetPlannedEndOfShift(PlanRegistration pr, int n) + { + return n switch + { + 1 => pr.PlannedEndOfShift1, + 2 => pr.PlannedEndOfShift2, + 3 => pr.PlannedEndOfShift3, + 4 => pr.PlannedEndOfShift4, + 5 => pr.PlannedEndOfShift5, + _ => 0 + }; + } + public async Task AcceptAsync( int requestId, int currentSdkSitId, @@ -317,12 +464,23 @@ public async Task AcceptAsync( return new OperationResult(false, _localizationService.GetString("PlanRegistrationsNotFound")); } - // Validate receiver is empty - var targetHasContent = toPR.PlanHoursInSeconds > 0 || !string.IsNullOrWhiteSpace(toPR.PlanText); - if (targetHasContent) + // Validate receiver is empty for the relevant scope. + if (request.ShiftIndex == null) { - return new OperationResult(false, - _localizationService.GetString("TargetPlanRegistrationMustBeEmpty")); + var targetHasContent = toPR.PlanHoursInSeconds > 0 || !string.IsNullOrWhiteSpace(toPR.PlanText); + if (targetHasContent) + { + return new OperationResult(false, + _localizationService.GetString("TargetPlanRegistrationMustBeEmpty")); + } + } + else + { + if (GetPlannedEndOfShift(toPR, request.ShiftIndex.Value) != 0) + { + return new OperationResult(false, + _localizationService.GetString("TargetPlanRegistrationMustBeEmpty")); + } } // Apply changes without explicit transaction @@ -330,8 +488,8 @@ public async Task AcceptAsync( // and using an explicit transaction was causing issues in the test environment try { - // Move content from source to target - MoveContent(fromPR, toPR); + // Move content from source to target (full day or shift-scoped) + MoveContent(fromPR, toPR, request.ShiftIndex); // Set audit fields if they exist try @@ -369,6 +527,68 @@ public async Task AcceptAsync( // Continue - audit field failure should not prevent handover } + // Recalculate derived fields (PlanHoursInSeconds / PlanHours / + // IsDoubleShift) BEFORE persisting. For the partial path, MoveShift + // zeros only the shift-scoped fields and leaves the PlanHours* + // totals stale — the recalc MUST succeed or we roll back in-memory + // state and return an error. For the legacy full-day path the + // derived fields are copied wholesale inside MoveContent so a + // recalc miss is not data-corrupting. + var fromAssignedSite = await _dbContext.AssignedSites + .FirstOrDefaultAsync(a => a.SiteId == fromPR.SdkSitId); + var toAssignedSite = await _dbContext.AssignedSites + .FirstOrDefaultAsync(a => a.SiteId == toPR.SdkSitId); + + if (request.ShiftIndex.HasValue) + { + if (fromAssignedSite == null || toAssignedSite == null) + { + _dbContext.ChangeTracker.Clear(); + return new OperationResult(false, + $"Cannot accept partial handover: AssignedSite missing for " + + (fromAssignedSite == null ? "source" : "target") + " worker"); + } + try + { + PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(fromAssignedSite, fromPR); + PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(toAssignedSite, toPR); + } + catch (Exception ex) + { + _logger.LogError(ex, "Recalc failed during partial handover accept for request {RequestId}", requestId); + _dbContext.ChangeTracker.Clear(); + return new OperationResult(false, + _localizationService.GetString("ErrorAcceptingHandoverRequest")); + } + } + else + { + // Legacy full-day path: best-effort recalc (source/target PlanHours + // was copied wholesale so a miss is not corrupting). + try + { + if (fromAssignedSite != null) + { + PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(fromAssignedSite, fromPR); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not recalculate source PlanRegistration {Id} after handover", fromPR.Id); + } + try + { + if (toAssignedSite != null) + { + PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(toAssignedSite, toPR); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not recalculate target PlanRegistration {Id} after handover", toPR.Id); + } + } + fromPR.UpdatedByUserId = _userService.UserId; toPR.UpdatedByUserId = _userService.UserId; await fromPR.Update(_dbContext); @@ -381,37 +601,6 @@ public async Task AcceptAsync( request.UpdatedByUserId = _userService.UserId; await request.Update(_dbContext); - // Recalculate both PlanRegistrations - only if AssignedSite exists - try - { - var fromAssignedSite = await _dbContext.AssignedSites - .FirstOrDefaultAsync(a => a.SiteId == fromPR.SdkSitId); - if (fromAssignedSite != null) - { - PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(fromAssignedSite, fromPR); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not recalculate source PlanRegistration {Id} after handover", fromPR.Id); - // Continue - recalculation failure should not prevent handover - } - - try - { - var toAssignedSite = await _dbContext.AssignedSites - .FirstOrDefaultAsync(a => a.SiteId == toPR.SdkSitId); - if (toAssignedSite != null) - { - PlanRegistrationHelper.CalculatePauseAutoBreakCalculationActive(toAssignedSite, toPR); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Could not recalculate target PlanRegistration {Id} after handover", toPR.Id); - // Continue - recalculation failure should not prevent handover - } - // Fire-and-forget push to sender var fromSdkSitId = request.FromSdkSitId; _ = Task.Run(async () => @@ -623,8 +812,14 @@ public async Task>> GetMin } } - private void MoveContent(PlanRegistration source, PlanRegistration target) + private void MoveContent(PlanRegistration source, PlanRegistration target, int? shiftIndex = null) { + if (shiftIndex.HasValue) + { + MoveShift(source, target, shiftIndex.Value); + return; + } + // Move planning fields from source to target target.PlanText = source.PlanText; target.PlanHours = source.PlanHours; @@ -732,6 +927,56 @@ private void MoveContent(PlanRegistration source, PlanRegistration target) } } + private static void MoveShift(PlanRegistration source, PlanRegistration target, int n) + { + switch (n) + { + case 1: + target.PlannedStartOfShift1 = source.PlannedStartOfShift1; + target.PlannedEndOfShift1 = source.PlannedEndOfShift1; + target.PlannedBreakOfShift1 = source.PlannedBreakOfShift1; + source.PlannedStartOfShift1 = 0; + source.PlannedEndOfShift1 = 0; + source.PlannedBreakOfShift1 = 0; + break; + case 2: + target.PlannedStartOfShift2 = source.PlannedStartOfShift2; + target.PlannedEndOfShift2 = source.PlannedEndOfShift2; + target.PlannedBreakOfShift2 = source.PlannedBreakOfShift2; + source.PlannedStartOfShift2 = 0; + source.PlannedEndOfShift2 = 0; + source.PlannedBreakOfShift2 = 0; + break; + case 3: + target.PlannedStartOfShift3 = source.PlannedStartOfShift3; + target.PlannedEndOfShift3 = source.PlannedEndOfShift3; + target.PlannedBreakOfShift3 = source.PlannedBreakOfShift3; + source.PlannedStartOfShift3 = 0; + source.PlannedEndOfShift3 = 0; + source.PlannedBreakOfShift3 = 0; + break; + case 4: + target.PlannedStartOfShift4 = source.PlannedStartOfShift4; + target.PlannedEndOfShift4 = source.PlannedEndOfShift4; + target.PlannedBreakOfShift4 = source.PlannedBreakOfShift4; + source.PlannedStartOfShift4 = 0; + source.PlannedEndOfShift4 = 0; + source.PlannedBreakOfShift4 = 0; + break; + case 5: + target.PlannedStartOfShift5 = source.PlannedStartOfShift5; + target.PlannedEndOfShift5 = source.PlannedEndOfShift5; + target.PlannedBreakOfShift5 = source.PlannedBreakOfShift5; + source.PlannedStartOfShift5 = 0; + source.PlannedEndOfShift5 = 0; + source.PlannedBreakOfShift5 = 0; + break; + } + + // Do NOT touch PlanText on partial. PlanHours/PlanHoursInSeconds/IsDoubleShift + // will be recomputed by PlanRegistrationHelper on BOTH rows in the caller. + } + private ContentHandoverRequestModel MapToModel(PlanRegistrationContentHandoverRequest request) { return new ContentHandoverRequestModel @@ -746,7 +991,8 @@ private ContentHandoverRequestModel MapToModel(PlanRegistrationContentHandoverRe RequestedAtUtc = request.RequestedAtUtc, RespondedAtUtc = request.RespondedAtUtc, RequestComment = null, // Entity doesn't have RequestComment - DecisionComment = request.DecisionComment + DecisionComment = request.DecisionComment, + ShiftIndex = request.ShiftIndex }; } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/IContentHandoverService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/IContentHandoverService.cs index 48445840d..03dd1275c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/IContentHandoverService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/IContentHandoverService.cs @@ -33,7 +33,8 @@ namespace TimePlanning.Pn.Services.ContentHandoverService; public interface IContentHandoverService { - Task> CreateAsync(int fromPlanRegistrationId, ContentHandoverRequestCreateModel model); + Task>> CreateAsync(int fromPlanRegistrationId, ContentHandoverRequestCreateModel model); + Task>> GetHandoverEligibleCoworkersAsync(DateTime date, List shiftIndices); Task AcceptAsync(int requestId, int currentSdkSitId, ContentHandoverDecisionModel model); Task RejectAsync(int requestId, int currentSdkSitId, ContentHandoverDecisionModel model); Task CancelAsync(int requestId, int currentSdkSitId); diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningContentHandoverGrpcService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningContentHandoverGrpcService.cs index 4fbb7dbf7..5865ab9ac 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningContentHandoverGrpcService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningContentHandoverGrpcService.cs @@ -27,7 +27,8 @@ public override async Task CreateContentHandover( var model = new ContentHandoverRequestCreateModel { ToSdkSitId = request.ToSdkSiteId, - RequestComment = request.RequestComment + RequestComment = request.RequestComment, + ShiftIndices = new System.Collections.Generic.List(request.ShiftIndices) }; var result = await _contentHandoverService.CreateAsync(request.FromPlanRegistrationId, model); @@ -40,7 +41,19 @@ public override async Task CreateContentHandover( if (result.Success && result.Model != null) { - response.Model = MapToGrpc(result.Model); + // Populate BOTH `model` (first row) for legacy/ancient clients AND + // `models` (full list) for new clients. For the single-row legacy + // full-day path this is a list of one; for partial multi-shift it + // is the N created rows and `model` is the first as a defensive + // fallback. + foreach (var item in result.Model) + { + response.Models.Add(MapToGrpc(item)); + } + if (result.Model.Count > 0) + { + response.Model = MapToGrpc(result.Model[0]); + } } return response; @@ -222,7 +235,8 @@ public override async Task GetHandoverElig }; } - var result = await _contentHandoverService.GetHandoverEligibleCoworkersAsync(parsedDate); + var shiftIndices = new System.Collections.Generic.List(request.ShiftIndices); + var result = await _contentHandoverService.GetHandoverEligibleCoworkersAsync(parsedDate, shiftIndices); var response = new GetHandoverEligibleCoworkersResponse { @@ -269,7 +283,8 @@ private static Grpc.ContentHandoverRequestModel MapToGrpc(CsContentHandoverReque RequestedAtUtc = m.RequestedAtUtc.ToString("yyyy-MM-ddTHH:mm:ss"), RespondedAtUtc = m.RespondedAtUtc?.ToString("yyyy-MM-ddTHH:mm:ss") ?? "", RequestComment = m.RequestComment ?? "", - DecisionComment = m.DecisionComment ?? "" + DecisionComment = m.DecisionComment ?? "", + ShiftIndex = m.ShiftIndex ?? 0 }; } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj index f685490c2..e1fc21733 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj @@ -29,7 +29,7 @@ - + From eab601409757ffc3b8e477fbe4c5980629ae72bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Wed, 15 Apr 2026 17:23:51 +0200 Subject: [PATCH 2/2] test: seed AssignedSite rows for partial-shift handover tests AcceptAsync hard-fails on the partial path when no AssignedSite exists for source/target (safety net against silent derived-field corruption). The new partial-shift tests did not seed these rows, so CI failed with "AssignedSite missing for source worker". Seed both sites in the shared SeedPartialShiftPairAsync helper. Co-Authored-By: Claude Opus 4.6 --- .../ContentHandoverServiceTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs index fbc3629a8..482eb8ab3 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs @@ -496,6 +496,19 @@ public async Task GetMineAsync_ReturnsRequestsFromSender() private async Task<(PlanRegistration source, PlanRegistration target)> SeedPartialShiftPairAsync(DateTime date) { + // Accept path recomputes derived fields via PlanRegistrationHelper, which + // requires an AssignedSite row per SdkSitId. Seed both before the plan rows. + if (!await TimePlanningPnDbContext.AssignedSites.AnyAsync(a => a.SiteId == 1)) + { + await new AssignedSite { SiteId = 1, CreatedByUserId = 1, UpdatedByUserId = 1 } + .Create(TimePlanningPnDbContext); + } + if (!await TimePlanningPnDbContext.AssignedSites.AnyAsync(a => a.SiteId == 2)) + { + await new AssignedSite { SiteId = 2, CreatedByUserId = 1, UpdatedByUserId = 1 } + .Create(TimePlanningPnDbContext); + } + var source = new PlanRegistration { Date = date,