Skip to content

Commit 404a5e6

Browse files
renemadsenclaude
andcommitted
feat: partial-shift content handover (server)
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 <noreply@anthropic.com>
1 parent 482308a commit 404a5e6

File tree

10 files changed

+706
-115
lines changed

10 files changed

+706
-115
lines changed

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

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

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);

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/ContentHandover/ContentHandoverRequestCreateModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2525
#nullable enable
2626
namespace TimePlanning.Pn.Infrastructure.Models.ContentHandover;
2727

28+
using System.Collections.Generic;
29+
2830
public class ContentHandoverRequestCreateModel
2931
{
3032
public int ToSdkSitId { get; set; }
3133
public string? RequestComment { get; set; }
34+
public List<int> ShiftIndices { get; set; } = new();
3235
}

0 commit comments

Comments
 (0)