Skip to content

Commit 3ff95a5

Browse files
authored
Merge pull request #1477 from microting/feat/partial-shift-handover
feat: partial-shift content handover (server)
2 parents 482308a + eab6014 commit 3ff95a5

File tree

10 files changed

+719
-115
lines changed

10 files changed

+719
-115
lines changed

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs

Lines changed: 233 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using System.Threading.Tasks;
34

45
using Microsoft.EntityFrameworkCore;
@@ -108,9 +109,11 @@ public async Task CreateAsync_CreatesHandoverRequest_WhenSourceHasContent()
108109
// Assert
109110
Assert.That(result.Success, Is.True);
110111
Assert.That(result.Model, Is.Not.Null);
111-
Assert.That(result.Model.Status, Is.EqualTo("Pending"));
112-
Assert.That(result.Model.FromSdkSitId, Is.EqualTo(1));
113-
Assert.That(result.Model.ToSdkSitId, Is.EqualTo(2));
112+
Assert.That(result.Model.Count, Is.EqualTo(1));
113+
Assert.That(result.Model[0].Status, Is.EqualTo("Pending"));
114+
Assert.That(result.Model[0].FromSdkSitId, Is.EqualTo(1));
115+
Assert.That(result.Model[0].ToSdkSitId, Is.EqualTo(2));
116+
Assert.That(result.Model[0].ShiftIndex, Is.Null);
114117
}
115118

116119
[Test]
@@ -486,4 +489,231 @@ public async Task GetMineAsync_ReturnsRequestsFromSender()
486489
Assert.That(result.Model.Count, Is.EqualTo(1));
487490
Assert.That(result.Model[0].FromSdkSitId, Is.EqualTo(1));
488491
}
492+
493+
// ---------------------------------------------------------------
494+
// Partial-shift handover tests.
495+
// ---------------------------------------------------------------
496+
497+
private async Task<(PlanRegistration source, PlanRegistration target)> SeedPartialShiftPairAsync(DateTime date)
498+
{
499+
// Accept path recomputes derived fields via PlanRegistrationHelper, which
500+
// requires an AssignedSite row per SdkSitId. Seed both before the plan rows.
501+
if (!await TimePlanningPnDbContext.AssignedSites.AnyAsync(a => a.SiteId == 1))
502+
{
503+
await new AssignedSite { SiteId = 1, CreatedByUserId = 1, UpdatedByUserId = 1 }
504+
.Create(TimePlanningPnDbContext);
505+
}
506+
if (!await TimePlanningPnDbContext.AssignedSites.AnyAsync(a => a.SiteId == 2))
507+
{
508+
await new AssignedSite { SiteId = 2, CreatedByUserId = 1, UpdatedByUserId = 1 }
509+
.Create(TimePlanningPnDbContext);
510+
}
511+
512+
var source = new PlanRegistration
513+
{
514+
Date = date,
515+
SdkSitId = 1,
516+
PlannedStartOfShift1 = 8 * 60,
517+
PlannedEndOfShift1 = 12 * 60,
518+
PlannedStartOfShift2 = 13 * 60,
519+
PlannedEndOfShift2 = 17 * 60,
520+
PlannedStartOfShift3 = 18 * 60,
521+
PlannedEndOfShift3 = 21 * 60,
522+
PlanHoursInSeconds = 11 * 3600,
523+
CreatedByUserId = 1,
524+
UpdatedByUserId = 1
525+
};
526+
await source.Create(TimePlanningPnDbContext);
527+
528+
var target = new PlanRegistration
529+
{
530+
Date = date,
531+
SdkSitId = 2,
532+
CreatedByUserId = 1,
533+
UpdatedByUserId = 1
534+
};
535+
await target.Create(TimePlanningPnDbContext);
536+
return (source, target);
537+
}
538+
539+
[Test]
540+
public async Task CreateAsync_SingleShift_CreatesOneRowWithShiftIndex()
541+
{
542+
var date = new DateTime(2024, 2, 1);
543+
var (source, _) = await SeedPartialShiftPairAsync(date);
544+
545+
var result = await _contentHandoverService.CreateAsync(source.Id,
546+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } });
547+
548+
Assert.That(result.Success, Is.True, result.Message);
549+
Assert.That(result.Model.Count, Is.EqualTo(1));
550+
Assert.That(result.Model[0].ShiftIndex, Is.EqualTo(2));
551+
552+
// Source/target planning untouched (pending only).
553+
var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id);
554+
Assert.That(s.PlannedEndOfShift2, Is.EqualTo(17 * 60));
555+
}
556+
557+
[Test]
558+
public async Task CreateAsync_MultiShift_CreatesRowPerShift()
559+
{
560+
var date = new DateTime(2024, 2, 2);
561+
var (source, _) = await SeedPartialShiftPairAsync(date);
562+
563+
var result = await _contentHandoverService.CreateAsync(source.Id,
564+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 3 } });
565+
566+
Assert.That(result.Success, Is.True, result.Message);
567+
Assert.That(result.Model.Count, Is.EqualTo(2));
568+
Assert.That(result.Model.Select(m => m.ShiftIndex).OrderBy(x => x),
569+
Is.EqualTo(new int?[] { 1, 3 }));
570+
}
571+
572+
[Test]
573+
public async Task CreateAsync_MultiShift_AllOrNothing_WhenOneInvalid()
574+
{
575+
var date = new DateTime(2024, 2, 3);
576+
var (source, _) = await SeedPartialShiftPairAsync(date);
577+
578+
// Shift 4 on source is empty — invalid.
579+
var result = await _contentHandoverService.CreateAsync(source.Id,
580+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 4 } });
581+
582+
Assert.That(result.Success, Is.False);
583+
// Per-shift error message should name the failing shift so the UI can
584+
// show which one blocked the batch.
585+
Assert.That(result.Message, Does.Contain("Shift 4"));
586+
587+
var rows = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests
588+
.Where(r => r.FromPlanRegistrationId == source.Id)
589+
.ToListAsync();
590+
Assert.That(rows, Is.Empty, "No rows should have been persisted (validation pre-flight blocks the whole batch)");
591+
}
592+
593+
[Test]
594+
public async Task CreateAsync_DifferentShifts_AreAllowed_SameShift_IsBlocked()
595+
{
596+
var date = new DateTime(2024, 2, 4);
597+
var (source, _) = await SeedPartialShiftPairAsync(date);
598+
599+
var first = await _contentHandoverService.CreateAsync(source.Id,
600+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1 } });
601+
Assert.That(first.Success, Is.True);
602+
603+
// Different shift: allowed.
604+
var second = await _contentHandoverService.CreateAsync(source.Id,
605+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } });
606+
Assert.That(second.Success, Is.True, second.Message);
607+
608+
// Same shift again: blocked.
609+
var third = await _contentHandoverService.CreateAsync(source.Id,
610+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1 } });
611+
Assert.That(third.Success, Is.False);
612+
}
613+
614+
[Test]
615+
public async Task CreateAsync_PendingFullDay_BlocksPartial_AndViceVersa()
616+
{
617+
var dateA = new DateTime(2024, 2, 5);
618+
var (sourceA, _) = await SeedPartialShiftPairAsync(dateA);
619+
var fullDay = await _contentHandoverService.CreateAsync(sourceA.Id,
620+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2 });
621+
Assert.That(fullDay.Success, Is.True);
622+
623+
var partialBlocked = await _contentHandoverService.CreateAsync(sourceA.Id,
624+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1 } });
625+
Assert.That(partialBlocked.Success, Is.False);
626+
627+
// Separate date to test the other direction.
628+
var dateB = new DateTime(2024, 2, 6);
629+
var (sourceB, _) = await SeedPartialShiftPairAsync(dateB);
630+
var partial = await _contentHandoverService.CreateAsync(sourceB.Id,
631+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } });
632+
Assert.That(partial.Success, Is.True);
633+
634+
var fullDayBlocked = await _contentHandoverService.CreateAsync(sourceB.Id,
635+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2 });
636+
Assert.That(fullDayBlocked.Success, Is.False);
637+
}
638+
639+
[Test]
640+
public async Task AcceptAsync_SingleShift_MovesOnlyThatShift()
641+
{
642+
var date = new DateTime(2024, 2, 7);
643+
var (source, target) = await SeedPartialShiftPairAsync(date);
644+
645+
var create = await _contentHandoverService.CreateAsync(source.Id,
646+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 2 } });
647+
Assert.That(create.Success, Is.True);
648+
649+
var requestId = create.Model[0].Id;
650+
var accept = await _contentHandoverService.AcceptAsync(requestId, 2,
651+
new ContentHandoverDecisionModel { DecisionComment = "ok" });
652+
Assert.That(accept.Success, Is.True, accept.Message);
653+
654+
var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id);
655+
var t = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(target.Id);
656+
657+
// Shift 2 moved
658+
Assert.That(s.PlannedEndOfShift2, Is.EqualTo(0));
659+
Assert.That(t.PlannedEndOfShift2, Is.EqualTo(17 * 60));
660+
// Shift 1 and 3 untouched on source
661+
Assert.That(s.PlannedEndOfShift1, Is.EqualTo(12 * 60));
662+
Assert.That(s.PlannedEndOfShift3, Is.EqualTo(21 * 60));
663+
}
664+
665+
[Test]
666+
public async Task AcceptAllShifts_EquivalentToFullDay()
667+
{
668+
var date = new DateTime(2024, 2, 8);
669+
var (source, target) = await SeedPartialShiftPairAsync(date);
670+
671+
var create = await _contentHandoverService.CreateAsync(source.Id,
672+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 2, 3 } });
673+
Assert.That(create.Success, Is.True, create.Message);
674+
675+
foreach (var m in create.Model)
676+
{
677+
var res = await _contentHandoverService.AcceptAsync(m.Id, 2,
678+
new ContentHandoverDecisionModel { DecisionComment = "ok" });
679+
Assert.That(res.Success, Is.True, res.Message);
680+
}
681+
682+
var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id);
683+
var t = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(target.Id);
684+
685+
Assert.That(s.PlannedEndOfShift1, Is.EqualTo(0));
686+
Assert.That(s.PlannedEndOfShift2, Is.EqualTo(0));
687+
Assert.That(s.PlannedEndOfShift3, Is.EqualTo(0));
688+
Assert.That(t.PlannedEndOfShift1, Is.EqualTo(12 * 60));
689+
Assert.That(t.PlannedEndOfShift2, Is.EqualTo(17 * 60));
690+
Assert.That(t.PlannedEndOfShift3, Is.EqualTo(21 * 60));
691+
}
692+
693+
[Test]
694+
public async Task RejectOneOfN_LeavesRemainingPending()
695+
{
696+
var date = new DateTime(2024, 2, 9);
697+
var (source, _) = await SeedPartialShiftPairAsync(date);
698+
699+
var create = await _contentHandoverService.CreateAsync(source.Id,
700+
new ContentHandoverRequestCreateModel { ToSdkSitId = 2, ShiftIndices = new() { 1, 2 } });
701+
Assert.That(create.Success, Is.True);
702+
703+
var shift1Id = create.Model.Single(m => m.ShiftIndex == 1).Id;
704+
var shift2Id = create.Model.Single(m => m.ShiftIndex == 2).Id;
705+
706+
var rej = await _contentHandoverService.RejectAsync(shift1Id, 2,
707+
new ContentHandoverDecisionModel { DecisionComment = "no" });
708+
Assert.That(rej.Success, Is.True);
709+
710+
var reloaded1 = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests.FindAsync(shift1Id);
711+
var reloaded2 = await TimePlanningPnDbContext.PlanRegistrationContentHandoverRequests.FindAsync(shift2Id);
712+
Assert.That(reloaded1.Status, Is.EqualTo(HandoverRequestStatus.Rejected));
713+
Assert.That(reloaded2.Status, Is.EqualTo(HandoverRequestStatus.Pending));
714+
715+
// Shift 1 still on source (not moved).
716+
var s = await TimePlanningPnDbContext.PlanRegistrations.FindAsync(source.Id);
717+
Assert.That(s.PlannedEndOfShift1, Is.EqualTo(12 * 60));
718+
}
489719
}

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/GrpcServices/TimePlanningContentHandoverGrpcServiceTests.cs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public async Task CreateContentHandover_Success_ReturnsModel()
4848
_service.CreateAsync(
4949
Arg.Any<int>(),
5050
Arg.Any<ContentHandoverRequestCreateModel>())
51-
.Returns(new OperationDataResult<CsContentHandoverRequestModel>(true, csModel));
51+
.Returns(new OperationDataResult<List<CsContentHandoverRequestModel>>(
52+
true, new List<CsContentHandoverRequestModel> { csModel }));
5253

5354
var request = new CreateContentHandoverRequest
5455
{
@@ -64,6 +65,9 @@ public async Task CreateContentHandover_Success_ReturnsModel()
6465
Assert.That(response.Success, Is.True);
6566
Assert.That(response.Model, Is.Not.Null);
6667
Assert.That(response.Model.Id, Is.EqualTo(42));
68+
// New contract: `models` is populated alongside the defensive `model`.
69+
Assert.That(response.Models, Has.Count.EqualTo(1));
70+
Assert.That(response.Models[0].Id, Is.EqualTo(42));
6771
Assert.That(response.Model.FromSdkSiteId, Is.EqualTo(10));
6872
Assert.That(response.Model.ToSdkSiteId, Is.EqualTo(20));
6973
Assert.That(response.Model.Date, Is.EqualTo("2026-04-03T00:00:00"));
@@ -82,7 +86,7 @@ public async Task CreateContentHandover_Failure_ReturnsError()
8286
_service.CreateAsync(
8387
Arg.Any<int>(),
8488
Arg.Any<ContentHandoverRequestCreateModel>())
85-
.Returns(new OperationDataResult<CsContentHandoverRequestModel>(false, "Something went wrong"));
89+
.Returns(new OperationDataResult<List<CsContentHandoverRequestModel>>(false, "Something went wrong"));
8690

8791
var request = new CreateContentHandoverRequest
8892
{
@@ -235,4 +239,104 @@ public async Task GetMyContentHandovers_ReturnsList()
235239
Assert.That(response.Models[0].Id, Is.EqualTo(5));
236240
Assert.That(response.Models[0].Status, Is.EqualTo("Accepted"));
237241
}
242+
243+
[Test]
244+
public async Task CreateContentHandover_FansOutRepeatedModels_WhenShiftIndicesProvided()
245+
{
246+
var csModels = new List<CsContentHandoverRequestModel>
247+
{
248+
new()
249+
{
250+
Id = 101,
251+
FromSdkSitId = 10,
252+
ToSdkSitId = 20,
253+
Date = new DateTime(2026, 4, 3),
254+
FromPlanRegistrationId = 100,
255+
ToPlanRegistrationId = 200,
256+
Status = "Pending",
257+
RequestedAtUtc = new DateTime(2026, 4, 3, 12, 0, 0),
258+
RequestComment = "Partial 1",
259+
ShiftIndex = 1
260+
},
261+
new()
262+
{
263+
Id = 102,
264+
FromSdkSitId = 10,
265+
ToSdkSitId = 20,
266+
Date = new DateTime(2026, 4, 3),
267+
FromPlanRegistrationId = 100,
268+
ToPlanRegistrationId = 200,
269+
Status = "Pending",
270+
RequestedAtUtc = new DateTime(2026, 4, 3, 12, 0, 0),
271+
RequestComment = "Partial 2",
272+
ShiftIndex = 2
273+
}
274+
};
275+
276+
ContentHandoverRequestCreateModel? captured = null;
277+
_service.CreateAsync(
278+
Arg.Any<int>(),
279+
Arg.Do<ContentHandoverRequestCreateModel>(m => captured = m))
280+
.Returns(new OperationDataResult<List<CsContentHandoverRequestModel>>(true, csModels));
281+
282+
var request = new CreateContentHandoverRequest
283+
{
284+
FromPlanRegistrationId = 100,
285+
ToSdkSiteId = 20,
286+
RequestComment = "Partial handover",
287+
ShiftIndices = { 1, 2 }
288+
};
289+
290+
var response = await _grpcService.CreateContentHandover(
291+
request, TestServerCallContextFactory.Create());
292+
293+
Assert.That(response.Success, Is.True);
294+
Assert.That(captured, Is.Not.Null);
295+
Assert.That(captured!.ShiftIndices, Is.EquivalentTo(new[] { 1, 2 }));
296+
297+
Assert.That(response.Models, Has.Count.EqualTo(2));
298+
Assert.That(response.Models[0].Id, Is.EqualTo(101));
299+
Assert.That(response.Models[0].ShiftIndex, Is.EqualTo(1));
300+
Assert.That(response.Models[1].Id, Is.EqualTo(102));
301+
Assert.That(response.Models[1].ShiftIndex, Is.EqualTo(2));
302+
303+
// `model` stays populated defensively (first item) for legacy clients.
304+
Assert.That(response.Model, Is.Not.Null);
305+
Assert.That(response.Model.Id, Is.EqualTo(101));
306+
}
307+
308+
[Test]
309+
public async Task GetHandoverEligibleCoworkers_PassesShiftIndicesToService()
310+
{
311+
List<int>? capturedShiftIndices = null;
312+
DateTime capturedDate = default;
313+
314+
_service.GetHandoverEligibleCoworkersAsync(
315+
Arg.Do<DateTime>(d => capturedDate = d),
316+
Arg.Do<List<int>>(s => capturedShiftIndices = s))
317+
.Returns(new OperationDataResult<List<HandoverCoworkerModel>>(
318+
true, new List<HandoverCoworkerModel>
319+
{
320+
new() { SdkSiteId = 20, SiteName = "Alice", PlanRegistrationId = 200 }
321+
}));
322+
323+
var request = new GetHandoverEligibleCoworkersRequest
324+
{
325+
Date = "2026-04-03T00:00:00Z",
326+
ShiftIndices = { 0, 2 }
327+
};
328+
329+
var response = await _grpcService.GetHandoverEligibleCoworkers(
330+
request, TestServerCallContextFactory.Create());
331+
332+
Assert.That(response.Success, Is.True);
333+
Assert.That(capturedShiftIndices, Is.Not.Null);
334+
Assert.That(capturedShiftIndices!, Is.EquivalentTo(new[] { 0, 2 }));
335+
Assert.That(capturedDate.Date, Is.EqualTo(new DateTime(2026, 4, 3)));
336+
Assert.That(response.Coworkers, Has.Count.EqualTo(1));
337+
Assert.That(response.Coworkers[0].SdkSiteId, Is.EqualTo(20));
338+
339+
await _service.Received(1).GetHandoverEligibleCoworkersAsync(
340+
Arg.Any<DateTime>(), Arg.Any<List<int>>());
341+
}
238342
}

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/ContentHandoverController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ public class ContentHandoverController(IContentHandoverService contentHandoverSe
3939

4040
[HttpPost]
4141
[Route("plan-registrations/{id}/handover-requests")]
42-
public async Task<OperationDataResult<ContentHandoverRequestModel>> Create(
43-
int id,
42+
public async Task<OperationDataResult<System.Collections.Generic.List<ContentHandoverRequestModel>>> Create(
43+
int id,
4444
[FromBody] ContentHandoverRequestCreateModel model)
4545
{
4646
return await _contentHandoverService.CreateAsync(id, model);

0 commit comments

Comments
 (0)