@lilaquadrat/studio 12.1.0 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/lib/models.d.ts +8 -1
  2. package/lib/models.js +8 -1
  3. package/lib/models.js.map +1 -1
  4. package/lib/services.d.ts +8 -1
  5. package/lib/services.js +8 -1
  6. package/lib/services.js.map +1 -1
  7. package/lib/src/Immutable.class.js +10 -4
  8. package/lib/src/Immutable.class.js.map +1 -1
  9. package/lib/src/aggegations/structures.aggregation.d.ts +6 -8
  10. package/lib/src/aggegations/structures.aggregation.js +3 -2
  11. package/lib/src/aggegations/structures.aggregation.js.map +1 -1
  12. package/lib/src/classes/paymentProviders/PaymentProviderFactory.class.d.ts +4 -0
  13. package/lib/src/classes/paymentProviders/PaymentProviderFactory.class.js +8 -0
  14. package/lib/src/classes/paymentProviders/PaymentProviderFactory.class.js.map +1 -0
  15. package/lib/src/classes/paymentProviders/StripePaymentProvider.class.d.ts +9 -0
  16. package/lib/src/classes/paymentProviders/StripePaymentProvider.class.js +49 -0
  17. package/lib/src/classes/paymentProviders/StripePaymentProvider.class.js.map +1 -0
  18. package/lib/src/classes/shiftPlanningModel.class.d.ts +6 -0
  19. package/lib/src/classes/shiftPlanningModel.class.js +26 -0
  20. package/lib/src/classes/shiftPlanningModel.class.js.map +1 -0
  21. package/lib/src/helpers/buildProductName.d.ts +7 -0
  22. package/lib/src/helpers/buildProductName.js +20 -0
  23. package/lib/src/helpers/buildProductName.js.map +1 -0
  24. package/lib/src/helpers/shiftPlanAllocation.d.ts +53 -0
  25. package/lib/src/helpers/shiftPlanAllocation.js +110 -0
  26. package/lib/src/helpers/shiftPlanAllocation.js.map +1 -0
  27. package/lib/src/helpers/shiftPlanCoverage.d.ts +2 -0
  28. package/lib/src/helpers/shiftPlanCoverage.js +4 -0
  29. package/lib/src/helpers/shiftPlanCoverage.js.map +1 -0
  30. package/lib/src/helpers/shiftPlanningReports.d.ts +7 -0
  31. package/lib/src/helpers/shiftPlanningReports.js +135 -0
  32. package/lib/src/helpers/shiftPlanningReports.js.map +1 -0
  33. package/lib/src/helpers/shiftPlanningSchemas.d.ts +143 -0
  34. package/lib/src/helpers/shiftPlanningSchemas.js +87 -0
  35. package/lib/src/helpers/shiftPlanningSchemas.js.map +1 -0
  36. package/lib/src/helpers/shiftPlanningTime.d.ts +24 -0
  37. package/lib/src/helpers/shiftPlanningTime.js +215 -0
  38. package/lib/src/helpers/shiftPlanningTime.js.map +1 -0
  39. package/lib/src/helpers/shiftPlanningValidation.d.ts +32 -0
  40. package/lib/src/helpers/shiftPlanningValidation.js +69 -0
  41. package/lib/src/helpers/shiftPlanningValidation.js.map +1 -0
  42. package/lib/src/helpers/structureMatch.d.ts +15 -0
  43. package/lib/src/helpers/structureMatch.js +72 -0
  44. package/lib/src/helpers/structureMatch.js.map +1 -0
  45. package/lib/src/interfaces/ListPlanningParticipant.d.ts +17 -0
  46. package/lib/src/interfaces/ListPlanningParticipant.js +2 -0
  47. package/lib/src/interfaces/ListPlanningParticipant.js.map +1 -0
  48. package/lib/src/interfaces/PaymentProviderBase.d.ts +14 -0
  49. package/lib/src/interfaces/PaymentProviderBase.js +2 -0
  50. package/lib/src/interfaces/PaymentProviderBase.js.map +1 -0
  51. package/lib/src/interfaces/PlanCoveragePreparation.d.ts +17 -0
  52. package/lib/src/interfaces/PlanCoveragePreparation.js +2 -0
  53. package/lib/src/interfaces/PlanCoveragePreparation.js.map +1 -0
  54. package/lib/src/interfaces/PlanningParticipantsResult.d.ts +12 -0
  55. package/lib/src/interfaces/PlanningParticipantsResult.js +2 -0
  56. package/lib/src/interfaces/PlanningParticipantsResult.js.map +1 -0
  57. package/lib/src/interfaces/ShiftAutofillResult.d.ts +6 -0
  58. package/lib/src/interfaces/ShiftAutofillResult.js +2 -0
  59. package/lib/src/interfaces/ShiftAutofillResult.js.map +1 -0
  60. package/lib/src/interfaces/ShiftReportContextOptions.d.ts +9 -0
  61. package/lib/src/interfaces/ShiftReportContextOptions.js +2 -0
  62. package/lib/src/interfaces/ShiftReportContextOptions.js.map +1 -0
  63. package/lib/src/models/customers.model.js +10 -0
  64. package/lib/src/models/customers.model.js.map +1 -1
  65. package/lib/src/models/employeeAbsence.model.d.ts +4 -0
  66. package/lib/src/models/employeeAbsence.model.js +11 -0
  67. package/lib/src/models/employeeAbsence.model.js.map +1 -0
  68. package/lib/src/models/historySeen.model.d.ts +4 -0
  69. package/lib/src/models/historySeen.model.js +30 -0
  70. package/lib/src/models/historySeen.model.js.map +1 -0
  71. package/lib/src/models/lists.model.js +2 -1
  72. package/lib/src/models/lists.model.js.map +1 -1
  73. package/lib/src/models/listsParticipants.model.js +11 -0
  74. package/lib/src/models/listsParticipants.model.js.map +1 -1
  75. package/lib/src/models/permissions.model.js +28 -0
  76. package/lib/src/models/permissions.model.js.map +1 -1
  77. package/lib/src/models/shift.model.d.ts +4 -0
  78. package/lib/src/models/shift.model.js +17 -0
  79. package/lib/src/models/shift.model.js.map +1 -0
  80. package/lib/src/models/shiftPlan.model.d.ts +4 -0
  81. package/lib/src/models/shiftPlan.model.js +14 -0
  82. package/lib/src/models/shiftPlan.model.js.map +1 -0
  83. package/lib/src/models/shiftPlanDays.model.d.ts +4 -0
  84. package/lib/src/models/shiftPlanDays.model.js +15 -0
  85. package/lib/src/models/shiftPlanDays.model.js.map +1 -0
  86. package/lib/src/models/shiftPlanningSettings.model.d.ts +4 -0
  87. package/lib/src/models/shiftPlanningSettings.model.js +10 -0
  88. package/lib/src/models/shiftPlanningSettings.model.js.map +1 -0
  89. package/lib/src/models/workAreas.model.d.ts +4 -0
  90. package/lib/src/models/workAreas.model.js +12 -0
  91. package/lib/src/models/workAreas.model.js.map +1 -0
  92. package/lib/src/services/customers.service.d.ts +12 -1
  93. package/lib/src/services/customers.service.js +27 -0
  94. package/lib/src/services/customers.service.js.map +1 -1
  95. package/lib/src/services/employeeAbsences.service.d.ts +18 -0
  96. package/lib/src/services/employeeAbsences.service.js +57 -0
  97. package/lib/src/services/employeeAbsences.service.js.map +1 -0
  98. package/lib/src/services/history.service.d.ts +15 -0
  99. package/lib/src/services/history.service.js +36 -0
  100. package/lib/src/services/history.service.js.map +1 -1
  101. package/lib/src/services/historySeen.service.d.ts +32 -0
  102. package/lib/src/services/historySeen.service.js +66 -0
  103. package/lib/src/services/historySeen.service.js.map +1 -0
  104. package/lib/src/services/listParticipants.service.d.ts +180 -12
  105. package/lib/src/services/listParticipants.service.js +426 -6
  106. package/lib/src/services/listParticipants.service.js.map +1 -1
  107. package/lib/src/services/lists.service.d.ts +9 -0
  108. package/lib/src/services/lists.service.js +51 -0
  109. package/lib/src/services/lists.service.js.map +1 -1
  110. package/lib/src/services/paymentProviders.service.d.ts +5 -0
  111. package/lib/src/services/paymentProviders.service.js +10 -0
  112. package/lib/src/services/paymentProviders.service.js.map +1 -1
  113. package/lib/src/services/shiftPlanDays.service.d.ts +22 -0
  114. package/lib/src/services/shiftPlanDays.service.js +80 -0
  115. package/lib/src/services/shiftPlanDays.service.js.map +1 -0
  116. package/lib/src/services/shiftPlanningSettings.service.d.ts +11 -0
  117. package/lib/src/services/shiftPlanningSettings.service.js +27 -0
  118. package/lib/src/services/shiftPlanningSettings.service.js.map +1 -0
  119. package/lib/src/services/shiftPlans.service.d.ts +24 -0
  120. package/lib/src/services/shiftPlans.service.js +139 -0
  121. package/lib/src/services/shiftPlans.service.js.map +1 -0
  122. package/lib/src/services/shifts.service.d.ts +46 -0
  123. package/lib/src/services/shifts.service.js +405 -0
  124. package/lib/src/services/shifts.service.js.map +1 -0
  125. package/lib/src/services/structures.service.d.ts +2 -0
  126. package/lib/src/services/structures.service.js +7 -0
  127. package/lib/src/services/structures.service.js.map +1 -1
  128. package/lib/src/services/workAreas.service.d.ts +18 -0
  129. package/lib/src/services/workAreas.service.js +67 -0
  130. package/lib/src/services/workAreas.service.js.map +1 -0
  131. package/lib/tests/buildProductName.spec.d.ts +1 -0
  132. package/lib/tests/buildProductName.spec.js +27 -0
  133. package/lib/tests/buildProductName.spec.js.map +1 -0
  134. package/lib/tests/customersServiceStructures.spec.d.ts +1 -0
  135. package/lib/tests/customersServiceStructures.spec.js +146 -0
  136. package/lib/tests/customersServiceStructures.spec.js.map +1 -0
  137. package/lib/tests/historySeenService.spec.d.ts +1 -0
  138. package/lib/tests/historySeenService.spec.js +159 -0
  139. package/lib/tests/historySeenService.spec.js.map +1 -0
  140. package/lib/tests/listParticipantsPlanning.spec.d.ts +1 -0
  141. package/lib/tests/listParticipantsPlanning.spec.js +479 -0
  142. package/lib/tests/listParticipantsPlanning.spec.js.map +1 -0
  143. package/lib/tests/listParticipantsServiceStructures.spec.d.ts +1 -0
  144. package/lib/tests/listParticipantsServiceStructures.spec.js +176 -0
  145. package/lib/tests/listParticipantsServiceStructures.spec.js.map +1 -0
  146. package/lib/tests/listsServiceCreateExternalProducts.spec.d.ts +1 -0
  147. package/lib/tests/listsServiceCreateExternalProducts.spec.js +91 -0
  148. package/lib/tests/listsServiceCreateExternalProducts.spec.js.map +1 -0
  149. package/lib/tests/paymentProviders.spec.d.ts +1 -0
  150. package/lib/tests/paymentProviders.spec.js +119 -0
  151. package/lib/tests/paymentProviders.spec.js.map +1 -0
  152. package/lib/tests/paymentProvidersServiceGetProduct.spec.d.ts +1 -0
  153. package/lib/tests/paymentProvidersServiceGetProduct.spec.js +33 -0
  154. package/lib/tests/paymentProvidersServiceGetProduct.spec.js.map +1 -0
  155. package/lib/tests/shiftPlanCoverage.spec.d.ts +1 -0
  156. package/lib/tests/shiftPlanCoverage.spec.js +189 -0
  157. package/lib/tests/shiftPlanCoverage.spec.js.map +1 -0
  158. package/lib/tests/shiftPlanning.spec.d.ts +1 -0
  159. package/lib/tests/shiftPlanning.spec.js +1162 -0
  160. package/lib/tests/shiftPlanning.spec.js.map +1 -0
  161. package/lib/tests/shiftPlanningTime.spec.d.ts +1 -0
  162. package/lib/tests/shiftPlanningTime.spec.js +90 -0
  163. package/lib/tests/shiftPlanningTime.spec.js.map +1 -0
  164. package/lib/tests/structureMatch.spec.d.ts +1 -0
  165. package/lib/tests/structureMatch.spec.js +106 -0
  166. package/lib/tests/structureMatch.spec.js.map +1 -0
  167. package/package.json +2 -2
@@ -0,0 +1,1162 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
2
+ import mongodb from 'mongo-mock';
3
+ import { ObjectId } from 'mongodb';
4
+ import Models from '../src/classes/models.class.js';
5
+ import plansService from '../src/services/shiftPlans.service.js';
6
+ import daysService from '../src/services/shiftPlanDays.service.js';
7
+ import shiftsService from '../src/services/shifts.service.js';
8
+ import settingsService from '../src/services/shiftPlanningSettings.service.js';
9
+ import absencesService from '../src/services/employeeAbsences.service.js';
10
+ import areasService from '../src/services/workAreas.service.js';
11
+ import AreaModel from '../src/models/workAreas.model.js';
12
+ import ShiftPlanModel from '../src/models/shiftPlan.model.js';
13
+ import DayModel from '../src/models/shiftPlanDays.model.js';
14
+ import ShiftModel from '../src/models/shift.model.js';
15
+ import SettingsModel from '../src/models/shiftPlanningSettings.model.js';
16
+ import AbsenceModel from '../src/models/employeeAbsence.model.js';
17
+ import ListsModel from '../src/models/lists.model.js';
18
+ import ParticipantsModel from '../src/models/listsParticipants.model.js';
19
+ import CustomersModel from '../src/models/customers.model.js';
20
+ import StructureModel from '../src/models/structure.model.js';
21
+ import HistoryModel from '../src/models/history.model.js';
22
+ import participantsService from '../src/services/listParticipants.service.js';
23
+ const options = { company: 'hotel', project: 'hotel-project', user: 'planner', app: 'studio' };
24
+ const listId = new ObjectId();
25
+ const categoryId = new ObjectId();
26
+ const customerId = new ObjectId();
27
+ const participantId = new ObjectId();
28
+ const secondCustomerId = new ObjectId();
29
+ const secondParticipantId = new ObjectId();
30
+ const housekeepingAreaId = new ObjectId();
31
+ const receptionAreaId = new ObjectId();
32
+ const day = '2026-10-05';
33
+ const nextDay = '2026-10-06';
34
+ const customer = {
35
+ _id: customerId, id: 'anna', company: options.company, type: 'person', name: 'Anna',
36
+ email: 'anna@example.org', emailConfirmed: true,
37
+ accounts: [{ account: 'anna-account', app: 'members', project: options.project }],
38
+ note: 'private',
39
+ };
40
+ const list = {
41
+ _id: listId, company: options.company, project: options.project, name: 'Hotel staff',
42
+ mode: 'reservation', state: 'closed', agreements: [],
43
+ categories: [{ _id: categoryId, id: 'housekeeping', name: 'Housekeeping' }],
44
+ };
45
+ const participant = {
46
+ _id: participantId, company: options.company, project: options.project, list: listId,
47
+ user: customerId, category: 'housekeeping', state: 'confirmed', agreements: [], note: 'private participant note',
48
+ };
49
+ const policy = () => ({
50
+ daytime: { start: '06:00', end: '22:00' }, nighttime: { start: '22:00', end: '06:00' },
51
+ maximumConsecutiveWorkingDays: { enabled: false, days: 6 },
52
+ maximumDailyWorkMinutes: { enabled: false, minutes: 480 },
53
+ minimumRest: { enabled: false, minutes: 660 },
54
+ breaks: { enabled: false, maximumContinuousWorkMinutes: 360, minimumQualifyingBreakMinutes: 15,
55
+ requirements: [{ afterWorkMinutes: 360, minimumTotalBreakMinutes: 30 }] },
56
+ absenceEnforcement: true,
57
+ });
58
+ const areaLinks = () => [{ area: housekeepingAreaId }, { area: receptionAreaId }];
59
+ const planInput = (list = listId, overrides = {}) => ({
60
+ list, name: 'Hotel week', timeZone: 'Europe/Berlin',
61
+ settingsOverrides: {},
62
+ ...overrides,
63
+ });
64
+ const planAreas = (plan) => plan.days.flatMap((entry) => entry.areas ?? []);
65
+ const dayId = (plan, date) => plan.days.find((entry) => entry.date === date)._id;
66
+ const createDays = async (plan, dayOptions, entries) => {
67
+ for (const entry of entries)
68
+ await daysService.createDay(plan, entry, dayOptions);
69
+ };
70
+ async function setupPlan() {
71
+ await settingsService.changeSettings({ settings: policy() }, options);
72
+ const created = await plansService.createPlan(planInput(), options);
73
+ await createDays(created._id, options, [{ date: day, areas: areaLinks() }, { date: nextDay, areas: areaLinks() }]);
74
+ return (await plansService.singlePlan(created._id, options));
75
+ }
76
+ async function otherProjectPlan(list, projectOptions, settingsOverrides = {}) {
77
+ const area = await areasService.createWorkArea({ name: 'Housekeeping' }, projectOptions);
78
+ const created = await plansService.createPlan(planInput(list, { settingsOverrides }), projectOptions);
79
+ await createDays(created._id, projectOptions, [
80
+ { date: day, areas: [{ area: area._id }] },
81
+ { date: nextDay, areas: [{ area: area._id }] },
82
+ ]);
83
+ return (await plansService.singlePlan(created._id, projectOptions));
84
+ }
85
+ const shiftInput = (plan, area, changes = {}) => {
86
+ const date = changes.day ?? day;
87
+ const dayDoc = plan.days.find((entry) => entry.date === date);
88
+ const base = {
89
+ area, title: 'Morning', start: `${date}T08:00:00+02:00`, end: `${date}T12:00:00+02:00`,
90
+ assignments: [{ participant: participantId }], ...changes,
91
+ };
92
+ return { ...base, day: dayDoc._id };
93
+ };
94
+ describe('shift planning', () => {
95
+ let client;
96
+ const models = [AreaModel, ShiftPlanModel, DayModel, ShiftModel, SettingsModel, AbsenceModel, ListsModel, ParticipantsModel, CustomersModel, StructureModel, HistoryModel];
97
+ beforeAll(async () => {
98
+ mongodb.max_delay = 0;
99
+ client = await mongodb.MongoClient.connect('mongodb://localhost:27017/shift-planning-tests');
100
+ const registry = new Models();
101
+ for (const model of models)
102
+ registry.register(model.name, model);
103
+ await Promise.all(registry.injectDatabaseConnection(client, undefined, true));
104
+ for (const model of [AreaModel, ShiftPlanModel, DayModel, ShiftModel, SettingsModel, AbsenceModel]) {
105
+ const collection = model.db;
106
+ const insertOne = collection.insertOne.bind(collection);
107
+ // mongo-mock otherwise generates IDs from its own older BSON implementation.
108
+ jest.spyOn(collection, 'insertOne').mockImplementation((async (document) => {
109
+ return insertOne({ ...document, _id: document._id ?? new ObjectId() });
110
+ }));
111
+ // Adapt mongo-mock's legacy driver wrapper to the production driver's document return.
112
+ jest.spyOn(collection, 'findOneAndUpdate').mockImplementation((async (filter, update) => {
113
+ await collection.updateOne(filter, update);
114
+ return collection.findOne(filter);
115
+ }));
116
+ }
117
+ const customers = CustomersModel.db;
118
+ const findCustomer = customers.findOne.bind(customers);
119
+ // mongo-mock's sift version matches separate elements for compound $elemMatch conditions.
120
+ jest.spyOn(customers, 'findOne').mockImplementation((async (filter, findOptions) => {
121
+ const result = await findCustomer(filter, findOptions);
122
+ const accounts = filter.accounts;
123
+ if (accounts && typeof accounts === 'object' && '$elemMatch' in accounts && result?.type === 'person') {
124
+ const expected = accounts.$elemMatch;
125
+ if (!result.accounts?.some((entry) => entry.account === expected.account && entry.app === expected.app && entry.project === expected.project))
126
+ return null;
127
+ }
128
+ return result;
129
+ }));
130
+ });
131
+ beforeEach(async () => {
132
+ for (const model of models) {
133
+ while (await model.db.countDocuments({}))
134
+ await model.db.deleteOne({});
135
+ }
136
+ await ListsModel.db.insertOne(list);
137
+ await CustomersModel.db.insertOne(customer);
138
+ await CustomersModel.db.insertOne({ ...customer, _id: secondCustomerId, id: 'ben', accounts: [] });
139
+ await ParticipantsModel.db.insertOne(participant);
140
+ await ParticipantsModel.db.insertOne({ ...participant, _id: secondParticipantId, user: secondCustomerId });
141
+ await AreaModel.db.insertOne({ _id: housekeepingAreaId, company: options.company, project: options.project, name: 'Housekeeping' });
142
+ await AreaModel.db.insertOne({ _id: receptionAreaId, company: options.company, project: options.project, name: 'Reception' });
143
+ });
144
+ afterAll(async () => {
145
+ jest.restoreAllMocks();
146
+ await client.close();
147
+ });
148
+ it('allows empty setup without company settings and links stable work areas on a closed list', async () => {
149
+ const empty = await plansService.createPlan(planInput(listId), options);
150
+ expect(empty.effectiveSettings).toBeNull();
151
+ await createDays(empty._id, options, [{ date: day, areas: areaLinks() }]);
152
+ const changed = (await plansService.singlePlan(empty._id, options));
153
+ expect(planAreas(changed)[0].area).toBeInstanceOf(ObjectId);
154
+ const again = await plansService.changePlan(empty._id, {
155
+ name: changed.name, timeZone: changed.timeZone, settingsOverrides: {},
156
+ }, options);
157
+ expect(planAreas(again)[0].area.equals(planAreas(changed)[0].area)).toBe(true);
158
+ expect(again).not.toHaveProperty('version');
159
+ expect((await settingsService.singleSettings(options))).toBeNull();
160
+ });
161
+ it('links, keeps and clears the employee list through full-object plan changes', async () => {
162
+ const otherListId = new ObjectId();
163
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, name: 'Events' });
164
+ const created = await plansService.createPlan(planInput(listId), options);
165
+ expect(created.list.equals(listId)).toBe(true);
166
+ const kept = await plansService.changePlan(created._id, planInput(), options);
167
+ expect(kept.list.equals(listId)).toBe(true);
168
+ const relinked = await plansService.changePlan(created._id, planInput(otherListId), options);
169
+ expect(relinked.list.equals(otherListId)).toBe(true);
170
+ const cleared = await plansService.changePlan(created._id, { name: created.name, timeZone: created.timeZone, settingsOverrides: {} }, options);
171
+ expect(cleared.list).toBeUndefined();
172
+ });
173
+ it('resolves assignments against the linked plan list for a shift persisted before the link', async () => {
174
+ await settingsService.changeSettings({ settings: policy() }, options);
175
+ const created = await plansService.createPlan({ name: 'Unlinked', timeZone: 'Europe/Berlin', settingsOverrides: {} }, options);
176
+ await createDays(created._id, options, [{ date: day, areas: [{ area: housekeepingAreaId }] }]);
177
+ const unlinked = (await plansService.singlePlan(created._id, options));
178
+ const shift = await shiftsService.createShift(unlinked._id, shiftInput(unlinked, planAreas(unlinked)[0].area, { assignments: [] }), options);
179
+ expect(shift).not.toHaveProperty('list');
180
+ await plansService.changePlan(created._id, planInput(), options);
181
+ const linked = (await plansService.singlePlan(created._id, options));
182
+ const updated = await shiftsService.replaceAssignments(linked._id, shift._id, { assignments: [{ participant: participantId }] }, options);
183
+ expect(updated.assignments).toHaveLength(1);
184
+ expect(updated.assignments[0].customer.toHexString()).toBe(customerId.toHexString());
185
+ });
186
+ it('creates separate same-name/same-date department plans from one employee pool', async () => {
187
+ const first = await setupPlan();
188
+ const count = await HistoryModel.db.countDocuments({});
189
+ const second = await plansService.createPlan(planInput(), options);
190
+ expect(second._id.equals(first._id)).toBe(false);
191
+ expect(await HistoryModel.db.countDocuments({})).toBe(count + 1);
192
+ await createDays(second._id, options, [{ date: day, areas: areaLinks() }, { date: nextDay, areas: areaLinks() }]);
193
+ const secondRead = (await plansService.singlePlan(second._id, options));
194
+ expect(planAreas(secondRead)[0].area.equals(planAreas(first)[0].area)).toBe(true);
195
+ });
196
+ it('validates work area category ownership and duplicate plan/day identities', async () => {
197
+ const plan = await setupPlan();
198
+ const area = planAreas(plan)[0].area;
199
+ await expect(plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [{ area, categoryIds: [new ObjectId()] }] }), options)).rejects.toThrow('VALIDATION_FAILED');
200
+ await expect(plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [{ area, categoryIds: [categoryId, categoryId] }] }), options)).rejects.toThrow('VALIDATION_FAILED');
201
+ await expect(plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [{ area, categoryIds: [categoryId] }, { area, categoryIds: [categoryId] }] }), options)).rejects.toThrow('VALIDATION_FAILED');
202
+ await expect(plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [{ area: new ObjectId(), categoryIds: [categoryId] }] }), options)).rejects.toThrow('VALIDATION_FAILED');
203
+ await daysService.createDay(plan._id, { date: '2026-10-19', areas: [] }, options);
204
+ await expect(daysService.createDay(plan._id, { date: '2026-10-19', name: 'Duplicate', areas: [] }, options)).rejects.toThrow('VALIDATION_FAILED');
205
+ });
206
+ it('binds and clears per-work-area categories on the plan', async () => {
207
+ const plan = await setupPlan();
208
+ const area = planAreas(plan)[0].area;
209
+ const bound = await plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [{ area, categoryIds: [categoryId] }] }), options);
210
+ expect(bound.workAreaCategories).toHaveLength(1);
211
+ expect(bound.workAreaCategories[0].categoryIds[0].equals(categoryId)).toBe(true);
212
+ const cleared = await plansService.changePlan(plan._id, planInput(listId, { workAreaCategories: [] }), options);
213
+ expect(cleared.workAreaCategories).toEqual([]);
214
+ const untouched = await plansService.changePlan(plan._id, planInput(listId), options);
215
+ expect(untouched.workAreaCategories).toEqual([]);
216
+ });
217
+ it('stores, preserves and clears the default participant state', async () => {
218
+ const plan = await setupPlan();
219
+ const set = await plansService.changePlan(plan._id, planInput(listId, { defaultParticipantState: 'confirmed' }), options);
220
+ expect(set.defaultParticipantState).toBe('confirmed');
221
+ const touched = await plansService.changePlan(plan._id, planInput(listId, { name: 'Renamed' }), options);
222
+ expect(touched.defaultParticipantState).toBe('confirmed');
223
+ const cleared = await plansService.changePlan(plan._id, planInput(listId, { defaultParticipantState: null }), options);
224
+ expect(cleared.defaultParticipantState).toBeNull();
225
+ await expect(plansService.changePlan(plan._id, planInput(listId, { defaultParticipantState: 'bogus' }), options)).rejects.toThrow('VALIDATION_FAILED');
226
+ });
227
+ it.each([
228
+ { timeZone: 'Bad/Timezone' },
229
+ ])('rejects invalid plan domain values: %j', async (changes) => {
230
+ await expect(plansService.createPlan({ ...planInput(), ...changes }, options)).rejects.toThrow('VALIDATION_FAILED');
231
+ expect(await ShiftPlanModel.db.countDocuments({})).toBe(0);
232
+ });
233
+ it.each([
234
+ { date: '2026-02-30' },
235
+ { date: '2026-10-15', areas: [{ area: housekeepingAreaId, unknown: true }] },
236
+ ])('rejects invalid day schema/domain values: %j', async (changes) => {
237
+ const plan = await setupPlan();
238
+ const count = await DayModel.db.countDocuments({});
239
+ await expect(daysService.createDay(plan._id, changes, options)).rejects.toThrow('VALIDATION_FAILED');
240
+ expect(await DayModel.db.countDocuments({})).toBe(count);
241
+ });
242
+ it('binds a shift to one work area and derives customers', async () => {
243
+ const plan = await setupPlan();
244
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[1].area, {
245
+ assignments: [{ participant: participantId }, { participant: secondParticipantId }],
246
+ }), options);
247
+ expect(shift.assignments.map((entry) => entry.customer.toHexString())).toEqual([customerId.toHexString(), secondCustomerId.toHexString()]);
248
+ expect(shift.area.equals(planAreas(plan)[1].area)).toBe(true);
249
+ expect(shift).not.toHaveProperty('version');
250
+ expect(shift.start).toBe(`${day}T06:00:00.000Z`);
251
+ expect(shift.violations).toEqual([]);
252
+ const separate = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
253
+ start: `${day}T13:00:00+02:00`, end: `${day}T15:00:00+02:00`,
254
+ }), options);
255
+ expect(separate.title).toBe(shift.title);
256
+ expect(separate._id.equals(shift._id)).toBe(false);
257
+ const history = await HistoryModel.db.findOne({ 'history.parent': shift._id });
258
+ expect(history).toMatchObject({ assignments: [{ customer: customerId }, { customer: secondCustomerId }], history: { model: 'shifts', reason: 'create', user: options.user } });
259
+ });
260
+ it('stores shift structures and people requirements and replaces them on change', async () => {
261
+ const plan = await setupPlan();
262
+ const structureId = '507f1f77bcf86cd799439011';
263
+ const secondStructureId = '507f1f77bcf86cd799439012';
264
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
265
+ structures: [{ structure: structureId, constraint: 'required', value: true }, { structure: secondStructureId, constraint: 'forbidden', value: 'option-b' }], requiredPeople: 3, matchingPeople: 2,
266
+ }), options);
267
+ expect(shift.structures).toEqual([{ structure: structureId, constraint: 'required', value: true }, { structure: secondStructureId, constraint: 'forbidden', value: 'option-b' }]);
268
+ expect(shift.requiredPeople).toBe(3);
269
+ expect(shift.matchingPeople).toBe(2);
270
+ const { assignments, ...cleared } = shiftInput(plan, planAreas(plan)[0].area, { requiredPeople: 4, matchingPeople: 1 });
271
+ const updated = await shiftsService.changeShift(plan._id, shift._id, cleared, options);
272
+ expect(updated.requiredPeople).toBe(4);
273
+ expect(updated.matchingPeople).toBe(1);
274
+ expect(updated).not.toHaveProperty('structures');
275
+ const { assignments: reused, ...restaffed } = shiftInput(plan, planAreas(plan)[0].area, { structures: [{ structure: structureId, constraint: 'required' }], requiredPeople: 2 });
276
+ const withStructures = await shiftsService.changeShift(plan._id, shift._id, restaffed, options);
277
+ expect(withStructures.structures).toEqual([{ structure: structureId, constraint: 'required' }]);
278
+ expect(withStructures.requiredPeople).toBe(2);
279
+ expect(withStructures.matchingPeople).toBe(1);
280
+ });
281
+ it('rejects duplicate customers, wrong participant ownership and unknown area', async () => {
282
+ const plan = await setupPlan();
283
+ await ParticipantsModel.db.updateOne({ _id: secondParticipantId }, { $set: { user: customerId } });
284
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
285
+ assignments: [{ participant: participantId }, { participant: secondParticipantId }],
286
+ }), options)).rejects.toThrow('VALIDATION_FAILED');
287
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { project: 'foreign' } });
288
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options)).rejects.toThrow('VALIDATION_FAILED');
289
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, new ObjectId()), options)).rejects.toThrow('VALIDATION_FAILED');
290
+ expect(await ShiftModel.db.countDocuments({})).toBe(0);
291
+ });
292
+ it.each(['blocked', 'unchecked', 'notuseful', 'canceled'])('refuses new %s participants', async (state) => {
293
+ const plan = await setupPlan();
294
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { state } });
295
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options)).rejects.toThrow('VALIDATION_FAILED');
296
+ });
297
+ it.each(['enlisted', 'confirmed', 'reserved', 'waiting', 'finalized', 'paid'])('accepts current admin-default state %s', async (state) => {
298
+ const plan = await setupPlan();
299
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { state } });
300
+ expect((await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options)).assignments).toHaveLength(1);
301
+ });
302
+ it('preserves canceled/removed assignments, reports attention and keeps own member projection private', async () => {
303
+ const plan = await setupPlan();
304
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
305
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { state: 'canceled' } });
306
+ expect((await shiftsService.singleShift(plan._id, shift._id, options)).unavailableParticipantIds).toEqual([participantId.toHexString()]);
307
+ await ParticipantsModel.db.deleteOne({ _id: participantId });
308
+ const before = await HistoryModel.db.countDocuments({ 'history.parent': shift._id });
309
+ await shiftsService.replaceAssignments(plan._id, shift._id, { assignments: [{ participant: participantId }] }, options);
310
+ expect(await HistoryModel.db.countDocuments({ 'history.parent': shift._id })).toBe(before);
311
+ const memberOptions = { ...options, user: 'anna-account', app: 'members' };
312
+ const own = await shiftsService.myShifts(customerId, {}, memberOptions);
313
+ expect(own.all).toBe(1);
314
+ expect(Object.keys(own.data[0]).sort()).toEqual(['_id', 'area', 'breaks', 'day', 'end', 'list', 'plan', 'start', 'title']);
315
+ expect(own.data[0].area).not.toHaveProperty('categoryIds');
316
+ expect(own.data[0]).not.toHaveProperty('history');
317
+ await expect(shiftsService.myShifts(secondCustomerId, {}, memberOptions)).rejects.toThrow('MEMBER_NOT_ALLOWED');
318
+ });
319
+ it('rejects mixed-account tenant/project/app membership instead of matching dotted fields independently', async () => {
320
+ await CustomersModel.db.updateOne({ _id: customerId }, { $set: { accounts: [
321
+ { account: 'anna-account', app: 'foreign', project: options.project },
322
+ { account: 'other', app: 'members', project: options.project },
323
+ ] } });
324
+ expect((await CustomersModel.db.findOne({ _id: customerId })).accounts).toEqual([
325
+ { account: 'anna-account', app: 'foreign', project: options.project },
326
+ { account: 'other', app: 'members', project: options.project },
327
+ ]);
328
+ await expect(shiftsService.myShifts(customerId, {}, { ...options, user: 'anna-account', app: 'members' })).rejects.toThrow('MEMBER_NOT_ALLOWED');
329
+ });
330
+ it('replaces assignment sets, retains empty sets and makes reordered sets a history no-op', async () => {
331
+ const plan = await setupPlan();
332
+ const assignments = [{ participant: participantId }, { participant: secondParticipantId }];
333
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { assignments }), options);
334
+ const count = await HistoryModel.db.countDocuments({ 'history.parent': shift._id });
335
+ await shiftsService.replaceAssignments(plan._id, shift._id, { assignments: [...assignments].reverse() }, options);
336
+ expect(await HistoryModel.db.countDocuments({ 'history.parent': shift._id })).toBe(count);
337
+ await shiftsService.replaceAssignments(plan._id, shift._id, { assignments: [] }, options);
338
+ expect((await shiftsService.singleShift(plan._id, shift._id, options)).assignments).toEqual([]);
339
+ expect(await HistoryModel.db.countDocuments({ 'history.parent': shift._id })).toBe(count + 1);
340
+ });
341
+ it('inherits settings and supports relaxing, disabling and restoring overrides', async () => {
342
+ const plan = await setupPlan();
343
+ const input = { name: plan.name, timeZone: plan.timeZone, settingsOverrides: {
344
+ minimumRest: { enabled: false, minutes: 60 }, absenceEnforcement: false,
345
+ } };
346
+ const local = await plansService.changePlan(plan._id, input, options);
347
+ expect(local.effectiveSettings.minimumRest).toEqual({ enabled: false, minutes: 60 });
348
+ const company = policy();
349
+ company.minimumRest = { enabled: true, minutes: 720 };
350
+ await settingsService.changeSettings({ settings: company }, options);
351
+ expect((await plansService.singlePlan(plan._id, options)).effectiveSettings.minimumRest.minutes).toBe(60);
352
+ const inherited = await plansService.changePlan(plan._id, { ...input, settingsOverrides: {} }, options);
353
+ expect(inherited.effectiveSettings.minimumRest.minutes).toBe(720);
354
+ expect(inherited.effectiveSettings.absenceEnforcement).toBe(true);
355
+ });
356
+ it('supports complete local policies without company settings and requires policy for assigned shifts', async () => {
357
+ const created = await plansService.createPlan(planInput(), options);
358
+ await createDays(created._id, options, [{ date: day, areas: areaLinks() }, { date: nextDay, areas: areaLinks() }]);
359
+ const plan = (await plansService.singlePlan(created._id, options));
360
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options)).rejects.toThrow('SHIFT_PLANNING_SETTINGS_NOT_CONFIGURED');
361
+ expect((await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { assignments: [] }), options)).assignments).toEqual([]);
362
+ const changed = await plansService.changePlan(plan._id, { ...planInput(), settingsOverrides: policy() }, options);
363
+ expect(changed.effectiveSettings).toEqual(policy());
364
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
365
+ });
366
+ it('records sickness against existing schedules, blocks new assignments and permits a local override', async () => {
367
+ const plan = await setupPlan();
368
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
369
+ const absence = await absencesService.createAbsence({ customer: customerId, type: 'sickness', start: `${day}T00:00:00+02:00`, end: `${nextDay}T00:00:00+02:00` }, options);
370
+ const existing = (await shiftsService.singleShift(plan._id, shift._id, options));
371
+ expect(existing.assignments).toHaveLength(1);
372
+ expect(existing.violations).toContainEqual({ customer: customerId.toHexString(), shift: shift._id.toHexString(), rule: 'absence', absence: absence._id.toHexString() });
373
+ expect(existing.violations.every((violation) => !('type' in violation))).toBe(true);
374
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
375
+ const expanded = await shiftsService.replaceAssignments(plan._id, shift._id, { assignments: [
376
+ { participant: participantId },
377
+ { participant: secondParticipantId },
378
+ ] }, options);
379
+ expect(expanded.assignments).toHaveLength(2);
380
+ expect(expanded.violations[0].rule).toBe('absence');
381
+ await plansService.changePlan(plan._id, { ...planInput(), settingsOverrides: { absenceEnforcement: false } }, options);
382
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { start: `${day}T13:00:00+02:00`, end: `${day}T14:00:00+02:00` }), options);
383
+ await expect(absencesService.singleAbsence(absence._id, { ...options, company: 'foreign' })).resolves.toBeNull();
384
+ await expect(absencesService.removeAbsence(absence._id, { ...options, company: 'foreign' })).rejects.toThrow('EMPLOYEE_ABSENCE_NOT_FOUND');
385
+ await absencesService.changeAbsence(absence._id, { customer: customerId, type: 'vacation', start: `${nextDay}T00:00:00+02:00`, end: `${nextDay}T12:00:00+02:00` }, options);
386
+ expect((await absencesService.listAbsences({ customer: customerId }, options)).all).toBe(1);
387
+ await absencesService.removeAbsence(absence._id, options);
388
+ expect((await absencesService.listAbsences({}, options)).data).toEqual([]);
389
+ expect(await HistoryModel.db.findOne({ 'history.parent': absence._id, 'history.reason': 'delete' })).not.toBeNull();
390
+ });
391
+ it('enforces qualifying staggered breaks and calculates net day/night working time', async () => {
392
+ const plan = await setupPlan();
393
+ const enabled = policy();
394
+ enabled.breaks.enabled = true;
395
+ await settingsService.changeSettings({ settings: enabled }, options);
396
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { end: `${day}T16:00:00+02:00` }), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
397
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
398
+ end: `${day}T16:00:00+02:00`, assignments: [
399
+ { participant: participantId, breaks: [{ start: `${day}T12:00:00+02:00`, end: `${day}T12:30:00+02:00` }] },
400
+ { participant: secondParticipantId, breaks: [{ start: `${day}T12:30:00+02:00`, end: `${day}T13:00:00+02:00` }] },
401
+ ],
402
+ }), options);
403
+ expect(shift.workingTime[0]).toMatchObject({ daytimeMinutes: 450, nighttimeMinutes: 0, breakMinutes: 30 });
404
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
405
+ end: `${day}T16:00:00+02:00`, assignments: [{ participant: participantId,
406
+ breaks: [{ start: `${day}T12:00:00+02:00`, end: `${day}T12:10:00+02:00` }] }],
407
+ }), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
408
+ });
409
+ it('accepts the exact work threshold but refuses a sufficiently long break taken too late', async () => {
410
+ const plan = await setupPlan();
411
+ const settings = policy();
412
+ settings.breaks.enabled = true;
413
+ await settingsService.changeSettings({ settings }, options);
414
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { end: `${day}T14:00:00+02:00` }), options);
415
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
416
+ end: `${day}T16:00:00+02:00`, assignments: [{ participant: participantId,
417
+ breaks: [{ start: `${day}T15:00:00+02:00`, end: `${day}T15:30:00+02:00` }] }],
418
+ }), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
419
+ });
420
+ it('splits overnight work at day/night boundaries and excludes actual breaks', async () => {
421
+ const plan = await setupPlan();
422
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
423
+ start: `${day}T21:30:00+02:00`, end: `${nextDay}T06:30:00+02:00`,
424
+ assignments: [{ participant: participantId,
425
+ breaks: [{ start: `${nextDay}T01:00:00+02:00`, end: `${nextDay}T01:30:00+02:00` }] }],
426
+ }), options);
427
+ expect(shift.workingTime[0]).toMatchObject({ daytimeMinutes: 60, nighttimeMinutes: 450, breakMinutes: 30 });
428
+ });
429
+ it.each([
430
+ [{ start: `${day}T07:00:00+02:00`, end: `${day}T08:30:00+02:00` }],
431
+ [{ start: `${day}T10:00:00+02:00`, end: `${day}T10:30:00+02:00` }, { start: `${day}T10:15:00+02:00`, end: `${day}T11:00:00+02:00` }],
432
+ ])('refuses breaks outside the shift or overlapping', async (...breaks) => {
433
+ const plan = await setupPlan();
434
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
435
+ assignments: [{ participant: participantId, breaks }],
436
+ }), options)).rejects.toThrow('VALIDATION_FAILED');
437
+ });
438
+ it('rejects impossible policy partitions and unordered thresholds without modifying stored settings', async () => {
439
+ await setupPlan();
440
+ const invalidPartition = policy();
441
+ invalidPartition.daytime.start = '07:00';
442
+ await expect(settingsService.changeSettings({ settings: invalidPartition }, options)).rejects.toThrow('VALIDATION_FAILED');
443
+ const invalidThresholds = policy();
444
+ invalidThresholds.breaks.requirements = [
445
+ { afterWorkMinutes: 540, minimumTotalBreakMinutes: 45 }, { afterWorkMinutes: 360, minimumTotalBreakMinutes: 30 },
446
+ ];
447
+ await expect(settingsService.changeSettings({ settings: invalidThresholds }, options)).rejects.toThrow('VALIDATION_FAILED');
448
+ expect((await settingsService.singleSettings(options)).settings).toEqual(policy());
449
+ });
450
+ it('reports inherited policy incompatibility after a company change without losing assignments', async () => {
451
+ const plan = await setupPlan();
452
+ await plansService.changePlan(plan._id, { ...planInput(), settingsOverrides: { daytime: policy().daytime } }, options);
453
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
454
+ const settings = policy();
455
+ settings.daytime = { start: '07:00', end: '23:00' };
456
+ settings.nighttime = { start: '23:00', end: '07:00' };
457
+ await settingsService.changeSettings({ settings }, options);
458
+ expect((await plansService.singlePlan(plan._id, options)).effectiveSettings).toBeNull();
459
+ const attention = (await shiftsService.singleShift(plan._id, shift._id, options));
460
+ expect(attention.assignments).toHaveLength(1);
461
+ expect(attention.violations[0].rule).toBe('settingsMissing');
462
+ });
463
+ it('returns an empty member envelope for orphaned references instead of inflated totals', async () => {
464
+ const plan = await setupPlan();
465
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
466
+ await ListsModel.db.deleteOne({ _id: listId });
467
+ expect(await shiftsService.myShifts(customerId, {}, { ...options, user: 'anna-account', app: 'members' }))
468
+ .toEqual({ data: [], all: 0, count: 0, sites: { first: 0, current: 0, last: 0, range: [0, 0] } });
469
+ });
470
+ it.each([
471
+ { start: '2026-02-30T08:00:00+02:00', end: '2026-03-01T12:00:00+02:00' },
472
+ { start: `${day}T12:00:00+02:00`, end: `${day}T08:00:00+02:00` },
473
+ { requiredPeople: -1 },
474
+ { matchingPeople: 1.5 },
475
+ { structures: [{ structure: '507f1f77bcf86cd799439011', constraint: 'bogus' }] },
476
+ { structures: [{ structure: '507f1f77bcf86cd799439011', constraint: 'required', value: {} }] },
477
+ { structures: [{ structure: 'x'.repeat(25), constraint: 'required' }] },
478
+ ])('rejects invalid shift schema/domain inputs %j', async (changes) => {
479
+ const plan = await setupPlan();
480
+ await expect(shiftsService.createShift(plan._id, { ...shiftInput(plan, planAreas(plan)[0].area), ...changes }, options)).rejects.toThrow('VALIDATION_FAILED');
481
+ });
482
+ it.each([
483
+ { date: '2026-03-29', start: '2026-03-29T00:00:00+01:00', end: '2026-03-29T06:00:00+02:00', minutes: 300 },
484
+ { date: '2026-10-25', start: '2026-10-25T00:00:00+02:00', end: '2026-10-25T06:00:00+01:00', minutes: 420 },
485
+ ])('calculates actual nighttime through DST on $date', async ({ date, start, end, minutes }) => {
486
+ await settingsService.changeSettings({ settings: policy() }, options);
487
+ const created = await plansService.createPlan(planInput(listId), options);
488
+ await createDays(created._id, options, [{ date, areas: areaLinks() }]);
489
+ const plan = (await plansService.singlePlan(created._id, options));
490
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { day: date, start, end }), options);
491
+ expect(shift.workingTime[0]).toMatchObject({ daytimeMinutes: 0, nighttimeMinutes: minutes });
492
+ });
493
+ it('counts overnight work on both days for consecutive-day enforcement', async () => {
494
+ const plan = await setupPlan();
495
+ const enabled = policy();
496
+ enabled.maximumConsecutiveWorkingDays = { enabled: true, days: 1 };
497
+ await settingsService.changeSettings({ settings: enabled }, options);
498
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { start: `${day}T22:00:00+02:00`, end: `${nextDay}T00:00:00+02:00` }), options);
499
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
500
+ start: `${day}T22:00:00+02:00`, end: `${nextDay}T01:00:00+02:00`,
501
+ }), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
502
+ });
503
+ it('checks consecutive work across projects and allows the incoming plan to relax the project rule', async () => {
504
+ const plan = await setupPlan();
505
+ const settings = policy();
506
+ settings.maximumConsecutiveWorkingDays = { enabled: true, days: 1 };
507
+ await settingsService.changeSettings({ settings }, options);
508
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
509
+ const otherListId = new ObjectId();
510
+ const otherParticipantId = new ObjectId();
511
+ const otherOptions = { ...options, project: 'other-project' };
512
+ await settingsService.changeSettings({ settings }, otherOptions);
513
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, project: otherOptions.project });
514
+ await ParticipantsModel.db.insertOne({ ...participant, _id: otherParticipantId, list: otherListId, project: otherOptions.project });
515
+ const otherPlan = await otherProjectPlan(otherListId, otherOptions);
516
+ const next = shiftInput(otherPlan, planAreas(otherPlan)[0].area, { day: nextDay, assignments: [{ participant: otherParticipantId }] });
517
+ await expect(shiftsService.createShift(otherPlan._id, next, otherOptions)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
518
+ await plansService.changePlan(otherPlan._id, { ...planInput(otherListId),
519
+ settingsOverrides: { maximumConsecutiveWorkingDays: { enabled: true, days: 2 } },
520
+ }, otherOptions);
521
+ expect((await shiftsService.createShift(otherPlan._id, next, otherOptions)).violations).toEqual([]);
522
+ });
523
+ it('enforces a maximum daily working time per person and lets a plan override it', async () => {
524
+ const plan = await setupPlan();
525
+ const area = planAreas(plan)[0].area;
526
+ const settings = policy();
527
+ settings.maximumDailyWorkMinutes = { enabled: true, minutes: 300 };
528
+ await settingsService.changeSettings({ settings }, options);
529
+ const morning = await shiftsService.createShift(plan._id, shiftInput(plan, area), options);
530
+ expect(morning.violations).toEqual([]);
531
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, area, {
532
+ start: `${day}T16:00:00+02:00`, end: `${day}T18:00:00+02:00`,
533
+ }), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
534
+ // A long break removes the minutes from the daily total, so the same span stays within the limit.
535
+ const reduced = await shiftsService.createShift(plan._id, shiftInput(plan, area, {
536
+ start: `${day}T16:00:00+02:00`, end: `${day}T18:00:00+02:00`,
537
+ assignments: [{ participant: participantId, breaks: [{ start: `${day}T16:00:00+02:00`, end: `${day}T17:00:00+02:00` }] }],
538
+ }), options);
539
+ expect(reduced.violations).toEqual([]);
540
+ await plansService.changePlan(plan._id, { ...planInput(), settingsOverrides: { maximumDailyWorkMinutes: { enabled: true, minutes: 600 } } }, options);
541
+ expect((await shiftsService.createShift(plan._id, shiftInput(plan, area, {
542
+ start: `${day}T18:00:00+02:00`, end: `${day}T19:00:00+02:00`,
543
+ }), options)).violations).toEqual([]);
544
+ });
545
+ it('refuses foreign-company customer references for assignments and absences', async () => {
546
+ const plan = await setupPlan();
547
+ await CustomersModel.db.updateOne({ _id: customerId }, { $set: { company: 'foreign' } });
548
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options)).rejects.toThrow('VALIDATION_FAILED');
549
+ await expect(absencesService.createAbsence({ customer: customerId, type: 'vacation', start: `${day}T00:00:00Z`, end: `${nextDay}T00:00:00Z` }, options)).rejects.toThrow('VALIDATION_FAILED');
550
+ });
551
+ it('protects referenced areas/days and timezone changes; shift edits retain assignments', async () => {
552
+ const plan = await setupPlan();
553
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { start: `${day}T00:30:00+02:00`, end: `${day}T04:00:00+02:00` }), options);
554
+ const config = { ...planInput(), settingsOverrides: {} };
555
+ await expect(daysService.changeDay(plan._id, dayId(plan, day), { date: day, areas: [planAreas(plan)[1]] }, options)).rejects.toThrow('SHIFT_PLAN_REFERENCES_IN_USE');
556
+ await expect(daysService.removeDay(plan._id, dayId(plan, day), options)).rejects.toThrow('SHIFT_PLAN_DAY_IN_USE');
557
+ await expect(plansService.changePlan(plan._id, { ...config, timeZone: 'UTC' }, options)).rejects.toThrow('SHIFT_PLAN_REFERENCES_IN_USE');
558
+ const changed = await shiftsService.changeShift(plan._id, shift._id, { area: planAreas(plan)[0].area, day: dayId(plan, day), title: 'Changed', start: `${day}T01:00:00+02:00`, end: `${day}T04:00:00+02:00` }, options);
559
+ expect(changed.assignments).toHaveLength(1);
560
+ expect(changed.title).toBe('Changed');
561
+ await shiftsService.removeShift(plan._id, shift._id, options);
562
+ expect(await shiftsService.singleShift(plan._id, shift._id, options)).toBeNull();
563
+ expect(await HistoryModel.db.findOne({ 'history.parent': shift._id, 'history.reason': 'delete' })).not.toBeNull();
564
+ });
565
+ it('filters overlapping intervals, assignment areas and pagination with inclusive/exclusive boundaries', async () => {
566
+ const plan = await setupPlan();
567
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
568
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[1].area, { start: `${day}T12:00:00+02:00`, end: `${day}T16:00:00+02:00` }), options);
569
+ const result = await shiftsService.listShifts(plan._id, { from: `${day}T12:00:00+02:00`, to: `${day}T16:00:00+02:00`, area: planAreas(plan)[1].area }, options);
570
+ expect(result).toMatchObject({ all: 1, count: 1, sites: { first: 1, current: 1, last: 1, range: [1, 1] } });
571
+ expect(result.data[0].start).toBe(`${day}T10:00:00.000Z`);
572
+ expect((await shiftsService.listShifts(plan._id, { site: 3 }, options)).data).toEqual([]);
573
+ await expect(shiftsService.listShifts(plan._id, { site: 0 }, options)).rejects.toThrow('VALIDATION_FAILED');
574
+ await expect(shiftsService.listShifts(plan._id, { from: `${day}T12:00:00Z`, to: `${day}T12:00:00Z` }, options)).rejects.toThrow('VALIDATION_FAILED');
575
+ await expect(plansService.singlePlan(plan._id, { ...options, company: 'foreign' })).resolves.toBeNull();
576
+ await expect(shiftsService.changeShift(plan._id, new ObjectId(), { area: planAreas(plan)[0].area, day: dayId(plan, day), title: 'Missing', start: `${day}T12:00:00Z`, end: `${day}T13:00:00Z` }, options)).rejects.toThrow('SHIFT_NOT_FOUND');
577
+ });
578
+ it('rejects cross-project double booking and enforces the upcoming plan rest policy even when editing an earlier shift', async () => {
579
+ const plan = await setupPlan();
580
+ const otherListId = new ObjectId();
581
+ const otherParticipantId = new ObjectId();
582
+ const otherOptions = { ...options, project: 'other-project' };
583
+ await settingsService.changeSettings({ settings: policy() }, otherOptions);
584
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, project: otherOptions.project });
585
+ await ParticipantsModel.db.insertOne({ ...participant, _id: otherParticipantId, project: otherOptions.project, list: otherListId });
586
+ const otherPlan = await otherProjectPlan(otherListId, otherOptions, { minimumRest: { enabled: true, minutes: 660 } });
587
+ const earlier = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
588
+ const later = await shiftsService.createShift(otherPlan._id, shiftInput(otherPlan, planAreas(otherPlan)[0].area, {
589
+ day: nextDay, title: 'Next morning', assignments: [{ participant: otherParticipantId }],
590
+ }), otherOptions);
591
+ await expect(shiftsService.changeShift(plan._id, earlier._id, {
592
+ area: planAreas(plan)[0].area, day: dayId(plan, day), title: 'Late', start: `${day}T08:00:00+02:00`, end: `${day}T23:00:00+02:00`,
593
+ }, options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
594
+ await plansService.changePlan(otherPlan._id, {
595
+ ...planInput(otherListId), settingsOverrides: { minimumRest: { enabled: true, minutes: 540 } },
596
+ }, otherOptions);
597
+ await shiftsService.changeShift(plan._id, earlier._id, {
598
+ area: planAreas(plan)[0].area, day: dayId(plan, day), title: 'Late', start: `${day}T08:00:00+02:00`, end: `${day}T23:00:00+02:00`,
599
+ }, options);
600
+ await expect(shiftsService.createShift(otherPlan._id, shiftInput(otherPlan, planAreas(otherPlan)[0].area, {
601
+ title: 'Overlap', start: `${day}T10:00:00+02:00`, end: `${day}T12:00:00+02:00`, assignments: [{ participant: otherParticipantId }],
602
+ }), otherOptions)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
603
+ expect((await shiftsService.singleShift(otherPlan._id, later._id, otherOptions)).violations).toEqual([]);
604
+ await absencesService.createAbsence({ customer: customerId, type: 'vacation', start: `${nextDay}T00:00:00Z`, end: `${nextDay}T23:00:00Z` }, otherOptions);
605
+ expect((await shiftsService.singleShift(otherPlan._id, later._id, otherOptions)).violations[0].rule).toBe('absence');
606
+ });
607
+ it('reads a plan without pulling in a sibling plan\'s overlapping shifts', async () => {
608
+ const plan = await setupPlan();
609
+ const siblingPlan = await setupPlan();
610
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area), options);
611
+ const siblingDay = siblingPlan.days.find((entry) => entry.date === day);
612
+ await ShiftModel.db.insertOne({
613
+ _id: new ObjectId(), company: options.company, project: options.project, plan: siblingPlan._id,
614
+ day: siblingDay._id, area: planAreas(siblingPlan)[0].area, list: listId, title: 'Sibling',
615
+ start: `${day}T10:00:00+02:00`, end: `${day}T14:00:00+02:00`,
616
+ assignments: [{ participant: participantId, customer: customerId, breaks: [] }],
617
+ });
618
+ expect((await shiftsService.listShifts(plan._id, {}, options)).data[0].violations).toEqual([]);
619
+ expect((await shiftsService.listShifts(siblingPlan._id, {}, options)).data[0].violations).toEqual([]);
620
+ });
621
+ it('attaches participant structure answers and definitions to reported shifts', async () => {
622
+ const plan = await setupPlan();
623
+ const structureId = new ObjectId();
624
+ await StructureModel.db.insertOne({
625
+ _id: structureId, id: 's-string-1', model: 'listsParticipants', company: options.company, project: options.project,
626
+ type: 'string', question: 'Color', max: 20,
627
+ });
628
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { 's-string-1': 'blue' } });
629
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
630
+ structures: [{ structure: structureId.toHexString(), constraint: 'required', value: 'blue' }],
631
+ }), options);
632
+ const assigned = shift.participants[0];
633
+ expect(assigned.structures).toEqual([expect.objectContaining({ id: 's-string-1' })]);
634
+ expect(assigned['s-string-1']).toBe('blue');
635
+ expect(assigned.user?.structures).toEqual([]);
636
+ });
637
+ it('rejects booking one person into overlapping shifts while allowing adjacency and other people', async () => {
638
+ const plan = await setupPlan();
639
+ const area = planAreas(plan)[0].area;
640
+ const morning = await shiftsService.createShift(plan._id, shiftInput(plan, area), options);
641
+ expect(morning.violations).toEqual([]);
642
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, area, {
643
+ start: `${day}T10:00:00+02:00`, end: `${day}T14:00:00+02:00`,
644
+ }), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
645
+ const adjacent = await shiftsService.createShift(plan._id, shiftInput(plan, area, {
646
+ start: `${day}T12:00:00+02:00`, end: `${day}T16:00:00+02:00`,
647
+ }), options);
648
+ expect(adjacent.violations).toEqual([]);
649
+ const other = await shiftsService.createShift(plan._id, shiftInput(plan, area, {
650
+ start: `${day}T10:00:00+02:00`, end: `${day}T14:00:00+02:00`, assignments: [{ participant: secondParticipantId }],
651
+ }), options);
652
+ expect(other.violations).toEqual([]);
653
+ await expect(shiftsService.changeShift(plan._id, adjacent._id, {
654
+ area, day: dayId(plan, day), title: adjacent.title, start: `${day}T11:00:00+02:00`, end: `${day}T16:00:00+02:00`,
655
+ }, options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
656
+ });
657
+ it('rejects worsening an existing rest violation but allows improving it after settings changed', async () => {
658
+ const plan = await setupPlan();
659
+ const earlier = await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, { end: `${day}T20:00:00+02:00` }), options);
660
+ await shiftsService.createShift(plan._id, shiftInput(plan, planAreas(plan)[0].area, {
661
+ day: nextDay, start: `${nextDay}T08:00:00+02:00`, end: `${nextDay}T12:00:00+02:00`,
662
+ }), options);
663
+ const settings = policy();
664
+ settings.minimumRest = { enabled: true, minutes: 840 };
665
+ await settingsService.changeSettings({ settings }, options);
666
+ await expect(shiftsService.changeShift(plan._id, earlier._id, {
667
+ area: planAreas(plan)[0].area, day: dayId(plan, day), title: 'Worse', start: `${day}T08:00:00+02:00`, end: `${day}T21:00:00+02:00`,
668
+ }, options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
669
+ const improved = await shiftsService.changeShift(plan._id, earlier._id, {
670
+ area: planAreas(plan)[0].area, day: dayId(plan, day), title: 'Improved', start: `${day}T08:00:00+02:00`, end: `${day}T19:00:00+02:00`,
671
+ }, options);
672
+ expect(improved.end).toBe(`${day}T17:00:00.000Z`);
673
+ });
674
+ it('creates, searches, replaces and deletes independent areas with scoped history', async () => {
675
+ const area = await areasService.createWorkArea({ id: 'wa-kitchen-a', name: 'Kitchen [A]', description: 'Old', location: 'Floor 1', address: { city: 'Berlin', country: 'DE' }, structures: [{ structure: '507f1f77bcf86cd799439011', constraint: 'required' }] }, options);
676
+ await areasService.createWorkArea({ name: 'Kitchen [A]' }, options);
677
+ const page = await areasService.listWorkAreas({ search: '[A]' }, options);
678
+ expect(page).toMatchObject({ all: 2, count: 2, sites: { first: 1, current: 1, last: 1, range: [1, 2] } });
679
+ expect(area._id).toBeInstanceOf(ObjectId);
680
+ expect(area.id).toBe('wa-kitchen-a');
681
+ expect(area.structures).toEqual([{ structure: '507f1f77bcf86cd799439011', constraint: 'required' }]);
682
+ expect(area).not.toHaveProperty('categoryIds');
683
+ expect(await areasService.singleWorkArea(area._id, { ...options, project: 'foreign' })).toBeNull();
684
+ await expect(areasService.changeWorkArea(area._id, { name: 'Foreign edit' }, { ...options, company: 'foreign' })).rejects.toThrow('WORK_AREA_NOT_FOUND');
685
+ const updated = await areasService.changeWorkArea(area._id, { name: 'Kitchen B', description: '', address: {} }, options);
686
+ expect(updated.id).toBe('wa-kitchen-a');
687
+ expect(updated).not.toHaveProperty('structures');
688
+ expect(updated).not.toHaveProperty('description');
689
+ expect(updated).not.toHaveProperty('location');
690
+ expect(updated).not.toHaveProperty('address');
691
+ expect(updated._id.equals(area._id)).toBe(true);
692
+ const restructured = await areasService.changeWorkArea(area._id, { name: 'Kitchen C', structures: [{ structure: '507f1f77bcf86cd799439011', constraint: 'required' }, { structure: '507f1f77bcf86cd799439012', constraint: 'forbidden', value: 'option-b' }] }, options);
693
+ expect(restructured.structures).toEqual([{ structure: '507f1f77bcf86cd799439011', constraint: 'required' }, { structure: '507f1f77bcf86cd799439012', constraint: 'forbidden', value: 'option-b' }]);
694
+ const history = await HistoryModel.db.findOne({ 'history.parent': area._id, 'history.reason': 'change' });
695
+ expect(history).toMatchObject({ name: 'Kitchen B', history: { model: 'workAreas', user: options.user } });
696
+ await areasService.removeWorkArea(area._id, options);
697
+ expect(await areasService.singleWorkArea(area._id, options)).toBeNull();
698
+ expect(await HistoryModel.db.findOne({ 'history.parent': area._id, 'history.reason': 'delete' })).not.toBeNull();
699
+ });
700
+ it.each([
701
+ { address: { private: 'value' } }, { name: '' }, { structures: [{ structure: '507f1f77bcf86cd799439011', constraint: 'bogus' }] },
702
+ ])('rejects invalid area metadata: %j', async (changes) => {
703
+ await expect(areasService.createWorkArea({ name: 'Kitchen', ...changes }, options)).rejects.toThrow('VALIDATION_FAILED');
704
+ expect(await AreaModel.db.countDocuments({})).toBe(2);
705
+ });
706
+ it('protects area deletion across all plans and permits plan-specific unlinking', async () => {
707
+ const first = await setupPlan();
708
+ const second = await plansService.createPlan(planInput(listId, { name: 'Events' }), options);
709
+ await createDays(second._id, options, [{ date: day, areas: areaLinks() }, { date: nextDay, areas: areaLinks() }]);
710
+ const secondRead = (await plansService.singlePlan(second._id, options));
711
+ const receptionLink = planAreas(first)[1];
712
+ await expect(areasService.removeWorkArea(housekeepingAreaId, options)).rejects.toThrow('WORK_AREA_IN_USE');
713
+ await daysService.changeDay(first._id, dayId(first, day), { date: day, areas: [receptionLink] }, options);
714
+ await daysService.changeDay(first._id, dayId(first, nextDay), { date: nextDay, areas: [receptionLink] }, options);
715
+ expect(planAreas(secondRead)[0].area.equals(housekeepingAreaId)).toBe(true);
716
+ await expect(areasService.removeWorkArea(housekeepingAreaId, options)).rejects.toThrow('WORK_AREA_IN_USE');
717
+ await daysService.changeDay(second._id, dayId(secondRead, day), { date: day, areas: [planAreas(secondRead)[1]] }, options);
718
+ await daysService.changeDay(second._id, dayId(secondRead, nextDay), { date: nextDay, areas: [planAreas(secondRead)[1]] }, options);
719
+ await areasService.removeWorkArea(housekeepingAreaId, options);
720
+ expect(await areasService.singleWorkArea(housekeepingAreaId, options)).toBeNull();
721
+ });
722
+ it('checks assignment references independently of plan links before deleting an area', async () => {
723
+ const plan = await setupPlan();
724
+ await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId), options);
725
+ // Simulate an externally damaged day: area deletion still protects the persisted assignment.
726
+ await DayModel.db.updateOne({ plan: plan._id, date: day }, { $set: { areas: [] } });
727
+ await expect(areasService.removeWorkArea(housekeepingAreaId, options)).rejects.toThrow('WORK_AREA_IN_USE');
728
+ });
729
+ it('shares area identity across weekly plans and reflects metadata changes in plans and member reads', async () => {
730
+ const first = await setupPlan();
731
+ const created = await plansService.createPlan(planInput(listId), options);
732
+ await createDays(created._id, options, [{ date: '2026-10-12', areas: areaLinks() }]);
733
+ const second = (await plansService.singlePlan(created._id, options));
734
+ await shiftsService.createShift(first._id, shiftInput(first, housekeepingAreaId), options);
735
+ await areasService.changeWorkArea(housekeepingAreaId, { name: 'Shared housekeeping', location: 'West wing' }, options);
736
+ const firstRead = (await plansService.singlePlan(first._id, options));
737
+ const secondRead = (await plansService.singlePlan(second._id, options));
738
+ expect(firstRead.workAreas[0].name).toBe('Shared housekeeping');
739
+ expect(secondRead.workAreas[0].name).toBe('Shared housekeeping');
740
+ expect(await AreaModel.db.countDocuments({})).toBe(2);
741
+ const member = await shiftsService.myShifts(customerId, {}, { ...options, user: 'anna-account', app: 'members' });
742
+ expect(member.data[0].area.name).toBe('Shared housekeeping');
743
+ expect(member.data[0].area).not.toHaveProperty('company');
744
+ expect(member.data[0].area).not.toHaveProperty('project');
745
+ });
746
+ it('searches plans by name while escaping regex metacharacters', async () => {
747
+ await setupPlan();
748
+ await plansService.createPlan(planInput(listId, { name: 'Events [October]' }), options);
749
+ const matched = await plansService.listPlans({ search: 'events' }, options);
750
+ expect(matched.all).toBe(1);
751
+ expect(matched.data[0].name).toBe('Events [October]');
752
+ const escaped = await plansService.listPlans({ search: '[October]' }, options);
753
+ expect(escaped.all).toBe(1);
754
+ expect((await plansService.listPlans({ search: 'missing' }, options)).all).toBe(0);
755
+ });
756
+ it('lists weekly plans by planning dates and keeps both bounds on the same day', async () => {
757
+ const first = await setupPlan();
758
+ const futureCreated = await plansService.createPlan(planInput(listId), options);
759
+ await createDays(futureCreated._id, options, [{ date: '2026-10-12', areas: areaLinks() }]);
760
+ const disjointCreated = await plansService.createPlan(planInput(listId), options);
761
+ await createDays(disjointCreated._id, options, [{ date: '2026-10-01', areas: areaLinks() }, { date: '2026-10-20', areas: areaLinks() }]);
762
+ // mongo-mock has no distinct implementation; reproduce the day-date range query over the stored day documents.
763
+ const storedDays = await DayModel.db.find({ company: options.company, project: options.project }).toArray();
764
+ const distinctSpy = jest.spyOn(DayModel.db, 'distinct').mockImplementation((async (_field, filter) => {
765
+ const range = (filter.date ?? {});
766
+ const plans = storedDays
767
+ .filter((entry) => (!range.$gte || entry.date >= range.$gte) && (!range.$lt || entry.date < range.$lt))
768
+ .map((entry) => entry.plan.toHexString());
769
+ return [...new Set(plans)].map((hex) => new ObjectId(hex));
770
+ }));
771
+ try {
772
+ const filtered = await plansService.listPlans({ list: listId, from: '2026-10-10', to: '2026-10-15' }, options);
773
+ expect(filtered.all).toBe(1);
774
+ expect(filtered.data[0]._id.equals(futureCreated._id)).toBe(true);
775
+ const ordered = await plansService.listPlans({ list: listId, from: day, to: '2026-10-13' }, options);
776
+ expect(ordered.all).toBe(2);
777
+ expect(ordered.data[0]._id.equals(first._id)).toBe(true);
778
+ await expect(plansService.listPlans({ list: listId, from: day, to: day }, options)).rejects.toThrow('VALIDATION_FAILED');
779
+ expect(await plansService.singlePlan(new ObjectId(), options)).toBeNull();
780
+ }
781
+ finally {
782
+ distinctSpy.mockRestore();
783
+ }
784
+ });
785
+ it('permits overlapping departments in one area while keeping shift operations bound to their plan', async () => {
786
+ const restaurant = await setupPlan();
787
+ const eventsCreated = await plansService.createPlan(planInput(listId, { name: 'Events' }), options);
788
+ await createDays(eventsCreated._id, options, [{ date: day, areas: areaLinks() }, { date: nextDay, areas: areaLinks() }]);
789
+ const events = (await plansService.singlePlan(eventsCreated._id, options));
790
+ const restaurantShift = await shiftsService.createShift(restaurant._id, shiftInput(restaurant, housekeepingAreaId), options);
791
+ const eventsShift = await shiftsService.createShift(events._id, shiftInput(events, housekeepingAreaId, {
792
+ assignments: [{ participant: secondParticipantId }],
793
+ }), options);
794
+ expect(eventsShift.violations).toEqual([]);
795
+ expect(await shiftsService.singleShift(events._id, restaurantShift._id, options)).toBeNull();
796
+ await expect(shiftsService.removeShift(events._id, restaurantShift._id, options)).rejects.toThrow('SHIFT_NOT_FOUND');
797
+ expect((await shiftsService.listShifts(restaurant._id, {}, options)).all).toBe(1);
798
+ await expect(shiftsService.createShift(events._id, shiftInput(events, housekeepingAreaId), options)).rejects.toThrow('SHIFT_PLANNING_RULE_VIOLATION');
799
+ });
800
+ it('keeps work area category bindings specific to each plan employee list', async () => {
801
+ const original = await setupPlan();
802
+ const departmentList = new ObjectId();
803
+ const departmentCategory = new ObjectId();
804
+ await ListsModel.db.insertOne({ ...list, _id: departmentList, categories: [{ _id: departmentCategory, id: 'events', name: 'Events' }] });
805
+ const eventsCreated = await plansService.createPlan(planInput(departmentList), options);
806
+ await createDays(eventsCreated._id, options, [
807
+ { date: day, areas: [{ area: housekeepingAreaId }] },
808
+ { date: nextDay, areas: [{ area: housekeepingAreaId }] },
809
+ ]);
810
+ const events = await plansService.changePlan(eventsCreated._id, planInput(departmentList, { workAreaCategories: [{ area: housekeepingAreaId, categoryIds: [departmentCategory] }] }), options);
811
+ expect(planAreas(events)[0].area.equals(planAreas(original)[0].area)).toBe(true);
812
+ expect(events.workAreaCategories[0].categoryIds[0].equals(departmentCategory)).toBe(true);
813
+ expect(original.workAreaCategories).toBeUndefined();
814
+ await expect(plansService.changePlan(events._id, planInput(departmentList, { workAreaCategories: [{ area: housekeepingAreaId, categoryIds: [categoryId] }] }), options)).rejects.toThrow('VALIDATION_FAILED');
815
+ });
816
+ it('rejects foreign-project or unlinked area identities', async () => {
817
+ const plan = await setupPlan();
818
+ const foreign = await areasService.createWorkArea({ name: 'Foreign area' }, { ...options, project: 'other-project' });
819
+ await expect(daysService.createDay(plan._id, { date: '2026-10-15', areas: [{ area: foreign._id }] }, options)).rejects.toThrow('VALIDATION_FAILED');
820
+ const unlinked = await areasService.createWorkArea({ name: 'Unused area' }, options);
821
+ await expect(shiftsService.createShift(plan._id, shiftInput(plan, unlinked._id), options)).rejects.toThrow('VALIDATION_FAILED');
822
+ expect(await AreaModel.db.countDocuments({ company: options.company, project: options.project })).toBe(3);
823
+ });
824
+ it('treats normalized break instants and input property order as an assignment no-op', async () => {
825
+ const plan = await setupPlan();
826
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, {
827
+ assignments: [{ participant: participantId, breaks: [{ start: `${day}T10:00:00+02:00`, end: `${day}T10:30:00+02:00` }] }],
828
+ }), options);
829
+ const count = await HistoryModel.db.countDocuments({ 'history.parent': shift._id });
830
+ await shiftsService.replaceAssignments(plan._id, shift._id, { assignments: [{ participant: participantId,
831
+ breaks: [{ end: `${day}T08:30:00Z`, start: `${day}T08:00:00Z` }],
832
+ }] }, options);
833
+ expect(await HistoryModel.db.countDocuments({ 'history.parent': shift._id })).toBe(count);
834
+ });
835
+ describe('read-only shift autofill', () => {
836
+ async function setupAutofill(changes = {}) {
837
+ const plan = await setupPlan();
838
+ const structureId = new ObjectId();
839
+ await StructureModel.db.insertOne({ _id: structureId, company: options.company, project: options.project,
840
+ model: 'listsParticipants', id: 's-boolean-preferred', type: 'boolean', question: 'Preferred' });
841
+ await ParticipantsModel.db.updateOne({ _id: secondParticipantId }, { $set: { 's-boolean-preferred': true } });
842
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, {
843
+ assignments: [], requiredPeople: 1,
844
+ structures: [{ structure: structureId.toHexString(), grade: 'very-positive' }], ...changes,
845
+ }), options);
846
+ return { plan, shift, structureId };
847
+ }
848
+ it('ranks suggestions deterministically, batches rule reads and changes no stored documents', async () => {
849
+ const { plan, shift } = await setupAutofill({ requiredPeople: 2 });
850
+ const before = await Promise.all(models.map(async (model) => JSON.stringify(await model.db.find({}).sort({ _id: 1 }).toArray())));
851
+ const settingsRead = jest.spyOn(settingsService, 'singleSettings');
852
+ const absencesRead = jest.spyOn(absencesService, 'absencesForPlanning');
853
+ try {
854
+ const expected = { shift: shift._id, users: [secondParticipantId, participantId] };
855
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual(expected);
856
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual(expected);
857
+ expect(settingsRead).toHaveBeenCalledTimes(2);
858
+ expect(absencesRead).toHaveBeenCalledTimes(2);
859
+ const after = await Promise.all(models.map(async (model) => JSON.stringify(await model.db.find({}).sort({ _id: 1 }).toArray())));
860
+ expect(after).toEqual(before);
861
+ }
862
+ finally {
863
+ settingsRead.mockRestore();
864
+ absencesRead.mockRestore();
865
+ }
866
+ });
867
+ it('keeps current assignments and only fills the remaining places', async () => {
868
+ const { plan, shift } = await setupAutofill({ requiredPeople: 2, assignments: [{ participant: participantId }] });
869
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId, secondParticipantId] });
870
+ expect((await ShiftModel.db.findOne({ _id: shift._id })).assignments).toEqual(shift.assignments);
871
+ });
872
+ it('retains existing attention items without allowing new violations', async () => {
873
+ const { plan, shift } = await setupAutofill({ requiredPeople: 2, assignments: [{ participant: participantId }] });
874
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { state: 'canceled' } });
875
+ await absencesService.createAbsence({ customer: customerId, type: 'sickness', start: `${day}T00:00:00+02:00`, end: `${nextDay}T00:00:00+02:00` }, options);
876
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId, secondParticipantId] });
877
+ });
878
+ it.each([undefined, 0, 1])('keeps an already assigned shift unchanged with target %p', async (requiredPeople) => {
879
+ const { plan, shift } = await setupAutofill({ requiredPeople, assignments: [{ participant: participantId }] });
880
+ const candidateRead = jest.spyOn(participantsService, 'planningParticipants');
881
+ try {
882
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
883
+ expect(candidateRead).not.toHaveBeenCalled();
884
+ }
885
+ finally {
886
+ candidateRead.mockRestore();
887
+ }
888
+ });
889
+ it('selects one list entry per person and returns a partial fill when the pool runs out', async () => {
890
+ const { plan, shift } = await setupAutofill({ requiredPeople: 3 });
891
+ const duplicateParticipantId = new ObjectId();
892
+ await ParticipantsModel.db.insertOne({ ...participant, _id: duplicateParticipantId, 's-boolean-preferred': true });
893
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [secondParticipantId, duplicateParticipantId] });
894
+ });
895
+ it.each(['blocked', 'unchecked', 'notuseful', 'canceled'])('excludes a high-scoring %s person', async (state) => {
896
+ const { plan, shift } = await setupAutofill();
897
+ await ParticipantsModel.db.updateOne({ _id: secondParticipantId }, { $set: { state } });
898
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
899
+ });
900
+ it('ignores entries without a person in the company', async () => {
901
+ const { plan, shift } = await setupAutofill();
902
+ await CustomersModel.db.updateOne({ _id: secondCustomerId }, { $set: { company: 'foreign' } });
903
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
904
+ });
905
+ it('applies the plan default state and area-category binding together', async () => {
906
+ const { plan, shift } = await setupAutofill({ requiredPeople: 3 });
907
+ const eventsCategoryId = new ObjectId();
908
+ const reservedCustomerId = new ObjectId();
909
+ const reservedParticipantId = new ObjectId();
910
+ await ListsModel.db.updateOne({ _id: listId }, { $set: { categories: [
911
+ ...list.categories, { _id: eventsCategoryId, id: 'events', name: 'Events' },
912
+ ] } });
913
+ await ParticipantsModel.db.updateOne({ _id: secondParticipantId }, { $set: { state: 'reserved', category: 'events' } });
914
+ await CustomersModel.db.insertOne({ ...customer, _id: reservedCustomerId, id: 'reserved', accounts: [] });
915
+ await ParticipantsModel.db.insertOne({ ...participant, _id: reservedParticipantId, user: reservedCustomerId, state: 'reserved' });
916
+ await plansService.changePlan(plan._id, planInput(listId, { defaultParticipantState: 'reserved',
917
+ workAreaCategories: [{ area: housekeepingAreaId, categoryIds: [categoryId] }],
918
+ }), options);
919
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [reservedParticipantId] });
920
+ });
921
+ it('ignores matchingPeople and fills only to requiredPeople', async () => {
922
+ const { plan, shift, structureId } = await setupAutofill({ requiredPeople: 2, matchingPeople: 2 });
923
+ await ShiftModel.db.updateOne({ _id: shift._id }, { $set: { structures: [
924
+ { structure: structureId.toHexString(), constraint: 'required', value: true, grade: 'very-negative' },
925
+ ] } });
926
+ // matchingPeople is a coverage concern only; autofill uses the ranked pool up to requiredPeople.
927
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id,
928
+ users: [secondParticipantId, participantId],
929
+ });
930
+ });
931
+ it('counts a retained assignment outside the candidate filter', async () => {
932
+ const { plan, shift } = await setupAutofill({ requiredPeople: 2, assignments: [{ participant: secondParticipantId }] });
933
+ await ParticipantsModel.db.updateOne({ _id: participantId }, { $set: { state: 'reserved' } });
934
+ await plansService.changePlan(plan._id, planInput(listId, { defaultParticipantState: 'reserved' }), options);
935
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [secondParticipantId, participantId] });
936
+ });
937
+ it('does not reject matchingPeople above requiredPeople', async () => {
938
+ const { plan, shift } = await setupAutofill({ requiredPeople: 1, matchingPeople: 2 });
939
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [secondParticipantId] });
940
+ });
941
+ it('skips an overlapping high-scoring person instead of double booking', async () => {
942
+ const { plan, shift } = await setupAutofill();
943
+ await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId, { assignments: [{ participant: secondParticipantId }] }), options);
944
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
945
+ });
946
+ it.each([true, false])('respects absence enforcement with plan override %p', async (absenceEnforcement) => {
947
+ const { plan, shift } = await setupAutofill();
948
+ await absencesService.createAbsence({ customer: secondCustomerId, type: 'sickness', start: `${day}T00:00:00+02:00`, end: `${nextDay}T00:00:00+02:00` }, options);
949
+ await plansService.changePlan(plan._id, planInput(listId, { settingsOverrides: { absenceEnforcement } }), options);
950
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id,
951
+ users: [absenceEnforcement ? participantId : secondParticipantId],
952
+ });
953
+ });
954
+ it.each(['rest', 'dailyHours', 'consecutiveDays'])('enforces the general %s rule', async (rule) => {
955
+ const { plan, shift } = await setupAutofill(rule === 'consecutiveDays'
956
+ ? { day: nextDay, start: `${nextDay}T08:00:00+02:00`, end: `${nextDay}T12:00:00+02:00` } : {});
957
+ await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId, {
958
+ assignments: [{ participant: secondParticipantId }],
959
+ ...(rule === 'consecutiveDays' ? {} : { start: `${day}T00:00:00+02:00`, end: `${day}T04:00:00+02:00` }),
960
+ }), options);
961
+ const settings = policy();
962
+ if (rule === 'rest')
963
+ settings.minimumRest = { enabled: true, minutes: 660 };
964
+ if (rule === 'dailyHours')
965
+ settings.maximumDailyWorkMinutes = { enabled: true, minutes: 420 };
966
+ if (rule === 'consecutiveDays')
967
+ settings.maximumConsecutiveWorkingDays = { enabled: true, days: 1 };
968
+ await settingsService.changeSettings({ settings }, options);
969
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
970
+ });
971
+ it('uses a plan override to relax the general daily hours limit', async () => {
972
+ const { plan, shift } = await setupAutofill();
973
+ await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId, {
974
+ assignments: [{ participant: secondParticipantId }], start: `${day}T00:00:00+02:00`, end: `${day}T04:00:00+02:00`,
975
+ }), options);
976
+ const settings = policy();
977
+ settings.maximumDailyWorkMinutes = { enabled: true, minutes: 420 };
978
+ await settingsService.changeSettings({ settings }, options);
979
+ await plansService.changePlan(plan._id, planInput(listId, { settingsOverrides: { maximumDailyWorkMinutes: { enabled: false, minutes: 420 } } }), options);
980
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [secondParticipantId] });
981
+ });
982
+ it('protects a future shift governed by another project plan rest override', async () => {
983
+ const { plan, shift } = await setupAutofill({ start: `${day}T20:00:00+02:00`, end: `${day}T23:00:00+02:00` });
984
+ const otherListId = new ObjectId();
985
+ const otherParticipantId = new ObjectId();
986
+ const otherOptions = { ...options, project: 'other-project' };
987
+ await settingsService.changeSettings({ settings: policy() }, otherOptions);
988
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, project: otherOptions.project });
989
+ await ParticipantsModel.db.insertOne({ ...participant, _id: otherParticipantId, user: secondCustomerId, project: otherOptions.project, list: otherListId });
990
+ const otherPlan = await otherProjectPlan(otherListId, otherOptions, { minimumRest: { enabled: true, minutes: 660 } });
991
+ await shiftsService.createShift(otherPlan._id, shiftInput(otherPlan, planAreas(otherPlan)[0].area, {
992
+ day: nextDay, assignments: [{ participant: otherParticipantId }],
993
+ }), otherOptions);
994
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
995
+ });
996
+ it('requires a valid shared break template when break rules demand it', async () => {
997
+ const { plan, shift } = await setupAutofill({ end: `${day}T16:00:00+02:00` });
998
+ const settings = policy();
999
+ settings.breaks.enabled = true;
1000
+ await settingsService.changeSettings({ settings }, options);
1001
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [] });
1002
+ const breaks = [{ start: `${day}T12:00:00+02:00`, end: `${day}T12:30:00+02:00` }];
1003
+ const originalBreaks = JSON.stringify(breaks);
1004
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options, breaks)).toStrictEqual({ shift: shift._id, users: [secondParticipantId] });
1005
+ expect(JSON.stringify(breaks)).toBe(originalBreaks);
1006
+ expect((await ShiftModel.db.findOne({ _id: shift._id })).assignments).toEqual([]);
1007
+ await expect(shiftsService.autofillShift(plan._id, shift._id, options, [{ start: `${day}T07:00:00+02:00`, end: `${day}T08:00:00+02:00` }])).rejects.toThrow('VALIDATION_FAILED');
1008
+ });
1009
+ it('requires effective settings but accepts complete plan overrides without general settings', async () => {
1010
+ const created = await plansService.createPlan(planInput(), options);
1011
+ await createDays(created._id, options, [{ date: day, areas: areaLinks() }]);
1012
+ const plan = (await plansService.singlePlan(created._id, options));
1013
+ const shift = await shiftsService.createShift(plan._id, {
1014
+ day: dayId(plan, day), area: housekeepingAreaId, title: 'Empty', start: `${day}T08:00:00+02:00`, end: `${day}T12:00:00+02:00`,
1015
+ requiredPeople: 1, assignments: [],
1016
+ }, options);
1017
+ await expect(shiftsService.autofillShift(plan._id, shift._id, options)).rejects.toThrow('SHIFT_PLANNING_SETTINGS_NOT_CONFIGURED');
1018
+ await plansService.changePlan(plan._id, planInput(listId, { settingsOverrides: policy() }), options);
1019
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [participantId] });
1020
+ });
1021
+ it('uses the current plan list rather than a stale shift list', async () => {
1022
+ const { plan, shift } = await setupAutofill();
1023
+ await plansService.changePlan(plan._id, { ...planInput(), list: undefined }, options);
1024
+ expect(await shiftsService.autofillShift(plan._id, shift._id, options)).toStrictEqual({ shift: shift._id, users: [] });
1025
+ });
1026
+ it('does not read a foreign tenant or another plan shift', async () => {
1027
+ const { plan, shift } = await setupAutofill();
1028
+ await expect(shiftsService.autofillShift(plan._id, shift._id, { ...options, company: 'foreign' })).rejects.toThrow('SHIFT_PLAN_NOT_FOUND');
1029
+ await expect(shiftsService.autofillShift(plan._id, shift._id, { ...options, project: 'foreign' })).rejects.toThrow('SHIFT_PLAN_NOT_FOUND');
1030
+ await expect(shiftsService.autofillShift(plan._id, new ObjectId(), options)).rejects.toThrow('SHIFT_NOT_FOUND');
1031
+ const sibling = await setupPlan();
1032
+ await expect(shiftsService.autofillShift(sibling._id, shift._id, options)).rejects.toThrow('SHIFT_NOT_FOUND');
1033
+ });
1034
+ it('returns the resolved suggestion as a report without persisting it', async () => {
1035
+ const { plan, shift } = await setupAutofill({ requiredPeople: 2 });
1036
+ const before = await ShiftModel.db.findOne({ _id: shift._id });
1037
+ const report = await shiftsService.autofillShiftReport(plan._id, shift._id, options);
1038
+ const suggested = [secondParticipantId.toHexString(), participantId.toHexString()];
1039
+ expect(report._id.equals(shift._id)).toBe(true);
1040
+ expect((report.assignments || []).map((assignment) => assignment.participant.toHexString())).toEqual(suggested);
1041
+ expect(report.participants.map((participant) => participant.participant.toHexString())).toEqual(suggested);
1042
+ // all scheduling rules run, so a clean suggestion has no violations
1043
+ expect(report.violations).toEqual([]);
1044
+ expect(await ShiftModel.db.findOne({ _id: shift._id })).toEqual(before);
1045
+ });
1046
+ });
1047
+ describe('scope shift autofill', () => {
1048
+ it('reuses preparation and reads the company conflict window only once', async () => {
1049
+ const plan = await setupPlan();
1050
+ await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [], requiredPeople: 1 }), options);
1051
+ const shiftReads = jest.spyOn(ShiftModel.db, 'find');
1052
+ const settingsReads = jest.spyOn(settingsService, 'singleSettings');
1053
+ const participantReads = jest.spyOn(ParticipantsModel.db, 'find');
1054
+ const customerReads = jest.spyOn(CustomersModel.db, 'find');
1055
+ const structureReads = jest.spyOn(StructureModel.db, 'find');
1056
+ const reports = await shiftsService.autofill({ level: 'plan', plan: plan._id }, options);
1057
+ expect(reports[0].assignments).toHaveLength(1);
1058
+ expect(shiftReads.mock.calls.filter(([query]) => query && 'assignments.customer' in query)).toHaveLength(1);
1059
+ expect(settingsReads).toHaveBeenCalledTimes(1);
1060
+ expect(participantReads).toHaveBeenCalledTimes(2);
1061
+ expect(customerReads).toHaveBeenCalledTimes(2);
1062
+ expect(structureReads).toHaveBeenCalledTimes(2);
1063
+ });
1064
+ it('does not expand the conflict window around distant neighbours', async () => {
1065
+ const plan = await setupPlan();
1066
+ const target = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [], requiredPeople: 1 }), options);
1067
+ const storedPlan = (await ShiftPlanModel.db.findOne({ _id: plan._id }));
1068
+ const storedShift = (await ShiftModel.db.findOne({ _id: target._id }));
1069
+ const neighbourPlan = new ObjectId();
1070
+ const distantPlan = new ObjectId();
1071
+ await ShiftPlanModel.db.insertMany([{ ...storedPlan, _id: neighbourPlan }, { ...storedPlan, _id: distantPlan }]);
1072
+ await ShiftModel.db.insertMany([
1073
+ { ...storedShift, _id: new ObjectId(), plan: neighbourPlan, start: '2027-08-01T08:00:00.000Z', end: '2027-08-01T12:00:00.000Z',
1074
+ assignments: [{ participant: secondParticipantId, customer: secondCustomerId, breaks: [] }] },
1075
+ { ...storedShift, _id: new ObjectId(), plan: distantPlan, start: '2028-05-01T08:00:00.000Z', end: '2028-05-01T12:00:00.000Z',
1076
+ assignments: [{ participant: secondParticipantId, customer: secondCustomerId, breaks: [] }] },
1077
+ ]);
1078
+ const planReads = jest.spyOn(plansService, 'plansForCompany');
1079
+ const reports = await shiftsService.autofill({ level: 'plan', plan: plan._id }, options);
1080
+ expect(reports[0].assignments).toHaveLength(1);
1081
+ expect(reports[0].violations).toEqual([]);
1082
+ expect(planReads).toHaveBeenCalledTimes(1);
1083
+ expect(planReads.mock.calls[0][0].map((id) => id.toHexString())).toEqual([neighbourPlan.toHexString()]);
1084
+ });
1085
+ it('still protects a future shift governed by another project rest override', async () => {
1086
+ const plan = await setupPlan();
1087
+ const target = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, {
1088
+ assignments: [], requiredPeople: 2, start: `${day}T20:00:00+02:00`, end: `${day}T23:00:00+02:00`,
1089
+ }), options);
1090
+ const otherListId = new ObjectId();
1091
+ const otherParticipantId = new ObjectId();
1092
+ const otherOptions = { ...options, project: 'other-project' };
1093
+ await settingsService.changeSettings({ settings: policy() }, otherOptions);
1094
+ await ListsModel.db.insertOne({ ...list, _id: otherListId, project: otherOptions.project });
1095
+ await ParticipantsModel.db.insertOne({ ...participant, _id: otherParticipantId, user: secondCustomerId, project: otherOptions.project, list: otherListId });
1096
+ const otherPlan = await otherProjectPlan(otherListId, otherOptions, { minimumRest: { enabled: true, minutes: 660 } });
1097
+ await shiftsService.createShift(otherPlan._id, shiftInput(otherPlan, planAreas(otherPlan)[0].area, {
1098
+ day: nextDay, assignments: [{ participant: otherParticipantId }],
1099
+ }), otherOptions);
1100
+ const reports = await shiftsService.autofill({ level: 'shift', plan: plan._id, shift: target._id }, options);
1101
+ expect(reports[0].assignments.map((assignment) => assignment.participant.toHexString())).toEqual([participantId.toHexString()]);
1102
+ expect(reports[0].violations).toEqual([]);
1103
+ });
1104
+ it('fills every under-staffed shift of a plan without double booking a person', async () => {
1105
+ const plan = await setupPlan();
1106
+ const morning = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, {
1107
+ assignments: [], requiredPeople: 1, start: `${day}T08:00:00+02:00`, end: `${day}T16:00:00+02:00`,
1108
+ }), options);
1109
+ const evening = await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId, {
1110
+ assignments: [], requiredPeople: 1, start: `${day}T12:00:00+02:00`, end: `${day}T20:00:00+02:00`,
1111
+ }), options);
1112
+ const reports = await shiftsService.autofill({ level: 'plan', plan: plan._id }, options);
1113
+ const byId = new Map(reports.map((report) => [report._id.toHexString(), report]));
1114
+ expect([...byId.keys()].sort()).toEqual([morning._id.toHexString(), evening._id.toHexString()].sort());
1115
+ const morningAssignee = byId.get(morning._id.toHexString()).assignments.map((assignment) => assignment.participant.toHexString());
1116
+ const eveningAssignee = byId.get(evening._id.toHexString()).assignments.map((assignment) => assignment.participant.toHexString());
1117
+ expect(morningAssignee).toHaveLength(1);
1118
+ expect(eveningAssignee).toHaveLength(1);
1119
+ // The shifts overlap, so the shared allocation cannot reuse the same person.
1120
+ expect(morningAssignee[0]).not.toBe(eveningAssignee[0]);
1121
+ });
1122
+ it('restricts a day scope to that day only', async () => {
1123
+ const plan = await setupPlan();
1124
+ const today = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [], requiredPeople: 1 }), options);
1125
+ const tomorrow = await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId, {
1126
+ assignments: [], requiredPeople: 1, day: nextDay, start: `${nextDay}T08:00:00+02:00`, end: `${nextDay}T16:00:00+02:00`,
1127
+ }), options);
1128
+ const reports = await shiftsService.autofill({ level: 'day', plan: plan._id, day: dayId(plan, day) }, options);
1129
+ expect(reports.map((report) => report._id.toHexString())).toEqual([today._id.toHexString()]);
1130
+ expect((await ShiftModel.db.findOne({ _id: tomorrow._id })).assignments).toEqual([]);
1131
+ });
1132
+ it('restricts a work area scope to that area only', async () => {
1133
+ const plan = await setupPlan();
1134
+ const housekeeping = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [], requiredPeople: 1 }), options);
1135
+ const reception = await shiftsService.createShift(plan._id, shiftInput(plan, receptionAreaId, { assignments: [], requiredPeople: 1 }), options);
1136
+ const reports = await shiftsService.autofill({ level: 'workArea', plan: plan._id, workArea: housekeepingAreaId }, options);
1137
+ expect(reports.map((report) => report._id.toHexString())).toEqual([housekeeping._id.toHexString()]);
1138
+ expect((await ShiftModel.db.findOne({ _id: reception._id })).assignments).toEqual([]);
1139
+ });
1140
+ it('fills a single shift for a shift scope without persisting it', async () => {
1141
+ const plan = await setupPlan();
1142
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [], requiredPeople: 1 }), options);
1143
+ const reports = await shiftsService.autofill({ level: 'shift', plan: plan._id, shift: shift._id }, options);
1144
+ expect(reports).toHaveLength(1);
1145
+ expect(reports[0]._id.equals(shift._id)).toBe(true);
1146
+ expect(reports[0].assignments).toHaveLength(1);
1147
+ expect(reports[0].violations).toEqual([]);
1148
+ expect((await ShiftModel.db.findOne({ _id: shift._id })).assignments).toEqual([]);
1149
+ });
1150
+ it('treats an overlapping shift in another plan as a conflict neighbour', async () => {
1151
+ const plan = await setupPlan();
1152
+ const shift = await shiftsService.createShift(plan._id, shiftInput(plan, housekeepingAreaId, { assignments: [], requiredPeople: 1 }), options);
1153
+ const sibling = await setupPlan();
1154
+ await shiftsService.createShift(sibling._id, shiftInput(sibling, housekeepingAreaId, {
1155
+ assignments: [{ participant: secondParticipantId }], start: `${day}T08:00:00+02:00`, end: `${day}T16:00:00+02:00`,
1156
+ }), options);
1157
+ const reports = await shiftsService.autofill({ level: 'shift', plan: plan._id, shift: shift._id }, options);
1158
+ expect(reports[0].assignments.map((assignment) => assignment.participant.toHexString())).toEqual([participantId.toHexString()]);
1159
+ });
1160
+ });
1161
+ });
1162
+ //# sourceMappingURL=shiftPlanning.spec.js.map