@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
@@ -2,7 +2,10 @@ import { ObjectId } from 'mongodb';
2
2
  import Immutable from '../Immutable.class.js';
3
3
  import ListParticipantsModel from '../models/listsParticipants.model.js';
4
4
  import ListsModel from '../models/lists.model.js';
5
- import { EditorService, HistoryService, ListsService, SpamAnalasysService } from '../../services.js';
5
+ import AreaModel from '../models/workAreas.model.js';
6
+ import ShiftModel from '../models/shift.model.js';
7
+ import ShiftPlanModel from '../models/shiftPlan.model.js';
8
+ import { EditorService, HistoryService, HistorySeenService, ListsService, SpamAnalasysService } from '../../services.js';
6
9
  import CustomersService from './customers.service.js';
7
10
  import hardCopy from '../helpers/hardCopy.js';
8
11
  import AgreementsError from '../classes/errors/Agreements.error.js';
@@ -13,9 +16,372 @@ import cleanObject from '../helpers/cleanObject.js';
13
16
  import StructureService from './structures.service.js';
14
17
  import CustomersModel from '../models/customers.model.js';
15
18
  import ImmutableError from '../classes/errors/Immutable.error.js';
16
- import { projectStructuresAggregation } from '../aggegations/structures.aggregation.js';
19
+ import { companyStructuresAggregation, projectStructuresAggregation } from '../aggegations/structures.aggregation.js';
20
+ import { structureScore } from '../helpers/structureMatch.js';
21
+ import { effectivePolicy } from '../helpers/shiftPlanningTime.js';
22
+ import { boundedPlanningInstant, unavailableParticipantStates } from '../helpers/shiftPlanningReports.js';
23
+ import { allocateShiftCoverage } from '../helpers/shiftPlanCoverage.js';
24
+ import AbsencesService from './employeeAbsences.service.js';
25
+ import SettingsService from './shiftPlanningSettings.service.js';
17
26
  import jetstreamService from './jetstream.service.js';
27
+ /** Structure answers live on the owning document under `s-` prefixed keys. */
28
+ function structureAnswers(document) {
29
+ return Object.fromEntries(Object.entries(document).filter(([key]) => key.startsWith('s-')));
30
+ }
31
+ /** Definitions the document actually answered. */
32
+ function resolvedStructures(answers, structures) {
33
+ const answered = new Set(Object.keys(answers));
34
+ return structures.filter((structure) => answered.has(structure.id));
35
+ }
36
+ /** Keeps the first occurrence of every id, so batched reads receive each document once. */
37
+ function uniqueObjectIds(ids) {
38
+ return [...new Map(ids.map((id) => [id.toHexString(), id])).values()];
39
+ }
40
+ function normalizePlanningScore(score, minimumScore, maximumScore) {
41
+ if (minimumScore === maximumScore)
42
+ return 4;
43
+ const position = (score - minimumScore) / (maximumScore - minimumScore);
44
+ if (position < 0.25)
45
+ return 1;
46
+ if (position < 0.5)
47
+ return 2;
48
+ if (position < 0.75)
49
+ return 3;
50
+ return 4;
51
+ }
18
52
  export class ListParticipantsService extends Immutable {
53
+ singleForPlanning(list, participant, options) {
54
+ return this.model.db.findOne({ _id: participant, list, company: options.company, project: options.project });
55
+ }
56
+ /** Reports retain unavailable entries; the scheduling layer applies eligibility only to new assignments. */
57
+ participantsForPlanning(participants, company) {
58
+ return this.model.db.find({ _id: { $in: participants }, company }).toArray();
59
+ }
60
+ /** Attaches each participant's answers, their list definitions and the customer's to the entry. */
61
+ resolveParticipantDetails(entries, customers, listStructures, customerStructures) {
62
+ const listStructuresByProject = new Map();
63
+ for (const structure of listStructures) {
64
+ const projectStructures = listStructuresByProject.get(structure.project);
65
+ if (projectStructures)
66
+ projectStructures.push(structure);
67
+ else
68
+ listStructuresByProject.set(structure.project, [structure]);
69
+ }
70
+ const companyCustomerStructures = customerStructures.filter((structure) => structure.project === undefined);
71
+ const customersById = new Map(customers.map((customer) => {
72
+ const answers = structureAnswers(customer);
73
+ return [customer._id.toHexString(), {
74
+ ...answers,
75
+ name: [customer.name, customer.prename].filter(Boolean).join(', '),
76
+ email: customer.email,
77
+ id: customer.id,
78
+ _id: customer._id,
79
+ structures: resolvedStructures(answers, companyCustomerStructures),
80
+ }];
81
+ }));
82
+ return entries.map((entry) => {
83
+ const customer = customersById.get(entry.user.toHexString());
84
+ const answers = structureAnswers(entry);
85
+ return {
86
+ ...answers,
87
+ ...entry,
88
+ structures: resolvedStructures(answers, listStructuresByProject.get(entry.project) ?? []),
89
+ user: customer ? {
90
+ ...customer,
91
+ structures: [...customer.structures],
92
+ } : undefined,
93
+ };
94
+ });
95
+ }
96
+ /** Same entries as participantsForPlanning, resolved with their structure answers and definitions for reporting. */
97
+ async participantsForPlanningWithDetails(participants, company) {
98
+ if (!participants.length)
99
+ return [];
100
+ return (await this.planningParticipantContext(participants, [], company)).participantDetails;
101
+ }
102
+ /** Raw availability and resolved answers share the same reads within one scheduling request. */
103
+ async planningParticipantContext(participantIds, customerIds, company, preparation) {
104
+ const participants = participantIds.length ? await this.participantsForPlanning(uniqueObjectIds(participantIds), company) : [];
105
+ const canReuseListStructures = preparation && participants.every((participant) => participant.project === preparation.plan.project);
106
+ const [customers, listStructures, customerStructures] = await Promise.all([
107
+ CustomersService.peopleForPlanning(uniqueObjectIds([...customerIds, ...participants.map((participant) => participant.user)]), company),
108
+ canReuseListStructures ? preparation.listStructures : StructureService.byModelAndCompany('listsParticipants', company),
109
+ preparation ? preparation.customerStructures : StructureService.byModelAndCompany('customers', company),
110
+ ]);
111
+ return { participants, customers, participantDetails: this.resolveParticipantDetails(participants, customers, listStructures, customerStructures) };
112
+ }
113
+ /**
114
+ * Candidates for a list scored against a planning scope, ordered best-first so pagination ranks
115
+ * the whole candidate set. `shift` and `workArea` resolve one condition set and return the pool;
116
+ * `day` and `plan` cover every shift in that scope and return its coverage, unless a membership
117
+ * mode is given, in which case they return the membership-filtered pool.
118
+ */
119
+ async planningParticipants(list, scope, state, categories, search, options,
120
+ /** Optional membership mode; absent makes a day/plan scope return coverage instead of the pool. */
121
+ membership, coverageMode = 'matching') {
122
+ // A membership mode asks for a candidate pool, so only an unfiltered day/plan scope returns coverage.
123
+ if ((scope.level === 'day' || scope.level === 'plan') && !membership) {
124
+ const coverage = await this.planCoverage(list, scope, options, coverageMode);
125
+ return coverage ? { all: 0, data: [], count: 0, coverage } : { all: 0, data: [], count: 0 };
126
+ }
127
+ const scored = await this.scoringCandidates(list, scope, state, categories, search, options);
128
+ if (!scored.length)
129
+ return { all: 0, data: [], count: 0 };
130
+ // Membership is a plan-level property applied to the ranked pool; `all` keeps everyone.
131
+ let candidates = scored;
132
+ if (membership && membership !== 'all') {
133
+ const members = await this.planMembership(scope, list, options);
134
+ candidates = scored.filter((candidate) => members.has(candidate._id.toHexString()) === (membership === 'assigned'));
135
+ }
136
+ if (!candidates.length)
137
+ return { all: 0, data: [], count: 0 };
138
+ let minimumScore = Infinity;
139
+ let maximumScore = -Infinity;
140
+ for (const candidate of candidates) {
141
+ minimumScore = Math.min(minimumScore, candidate.match.score);
142
+ maximumScore = Math.max(maximumScore, candidate.match.score);
143
+ }
144
+ const data = candidates.slice(options.skip, options.skip + options.limit).map((participant) => ({
145
+ _id: participant._id,
146
+ category: participant.category,
147
+ state: participant.state,
148
+ match: { ...participant.match, normalizedScore: normalizePlanningScore(participant.match.score, minimumScore, maximumScore) },
149
+ user: participant.user ? {
150
+ _id: participant.user._id,
151
+ id: participant.user.id,
152
+ name: participant.user.name,
153
+ email: participant.user.email,
154
+ } : undefined,
155
+ }));
156
+ return { all: candidates.length, data, count: data.length };
157
+ }
158
+ /** Conditions a scope is scored against; the work area's are used when a shift has none of its own. */
159
+ async scopeConditions(scope, options) {
160
+ const company = options.company;
161
+ const project = options.project;
162
+ const shift = scope.shift
163
+ ? await ShiftModel.db.findOne({ _id: scope.shift, company, project }, { projection: { structures: 1 } })
164
+ : undefined;
165
+ if (shift?.structures?.length)
166
+ return shift.structures;
167
+ if (!scope.workArea)
168
+ return [];
169
+ const area = await AreaModel.db.findOne({ _id: scope.workArea, company, project }, { projection: { structures: 1 } });
170
+ return area?.structures ?? [];
171
+ }
172
+ /** Participants holding at least one assignment anywhere in the scope's plan; empty when no plan exists. */
173
+ async planMembership(scope, list, options) {
174
+ const company = options.company;
175
+ const project = options.project;
176
+ // A shift knows its plan directly; other scopes fall back to the list's linked plan.
177
+ const plan = scope.plan ?? (scope.shift
178
+ ? (await ShiftModel.db.findOne({ _id: scope.shift, company, project }, { projection: { plan: 1 } }))?.plan
179
+ : (await ListsService.singleForPlanning(list, options))?.shiftPlan);
180
+ if (!plan)
181
+ return new Set();
182
+ const shifts = await ShiftModel.db
183
+ .find({ company, project, plan }, { projection: { assignments: 1 } })
184
+ .toArray();
185
+ return new Set(shifts.flatMap((shift) => shift.assignments.map((assignment) => assignment.participant.toHexString())));
186
+ }
187
+ async scoringCandidates(list, scope, state, categories, search, options) {
188
+ let categoryFilter;
189
+ if (categories?.length) {
190
+ // Participants store the list category's `id`; the plan binds its `_id`, so resolve here.
191
+ const listDocument = await ListsService.singleForPlanning(list, options);
192
+ const requested = new Set(categories.map((category) => category.toHexString()));
193
+ const ids = (listDocument?.categories ?? []).filter((category) => requested.has(category._id.toHexString())).map((category) => category.id);
194
+ if (!ids.length)
195
+ return [];
196
+ categoryFilter = { $in: ids };
197
+ }
198
+ // Conditions are only resolved once a requested category exists, so no-op filters skip the queries.
199
+ const conditions = await this.scopeConditions(scope, options);
200
+ return this.scoredEntries(list, conditions, state, categoryFilter, search, options);
201
+ }
202
+ /** Definitions that matter to one condition set, split by the model they belong to. */
203
+ scoringStructures(conditions, listStructures, customerStructures) {
204
+ const ids = new Set(conditions.map((condition) => condition.structure));
205
+ return {
206
+ list: listStructures.filter((structure) => ids.has(structure._id.toHexString())),
207
+ customer: customerStructures.filter((structure) => structure.project === undefined && ids.has(structure._id.toHexString())),
208
+ };
209
+ }
210
+ /**
211
+ * Scores resolved entries against one condition set and orders the feasible ones first. Unfit
212
+ * candidates fail a required/forbidden constraint, so they rank after every feasible one.
213
+ */
214
+ scoreEntries(entries, customers, conditions, listStructures, customerStructures) {
215
+ return this.resolveParticipantDetails(entries, customers, listStructures, customerStructures)
216
+ .map((participant) => ({ ...participant, match: structureScore(conditions, participant) }))
217
+ // Stable descending sort keeps the `_id` order from the query for equal scores.
218
+ .sort((left, right) => (left.match.state === 'unfit' ? 1 : 0) - (right.match.state === 'unfit' ? 1 : 0) || right.match.score - left.match.score);
219
+ }
220
+ /** Resolves the list's entries against one condition set and orders them best-first. */
221
+ async scoredEntries(list, conditions, state, categoryFilter, search, options) {
222
+ const company = options.company;
223
+ const project = options.project;
224
+ const stateFilter = state?.length
225
+ ? { $in: state }
226
+ : { $nin: ['blocked', 'unchecked', 'notuseful'] };
227
+ const [listStructures, customerStructures] = await Promise.all([
228
+ StructureService.getByModel('listsParticipants', { company, project }),
229
+ StructureService.byModelAndCompany('customers', company),
230
+ ]);
231
+ const scoring = this.scoringStructures(conditions, listStructures, customerStructures);
232
+ const answerProjection = Object.fromEntries([...scoring.list, ...scoring.customer].map((structure) => [structure.id, 1]));
233
+ const entries = await this.model.db
234
+ .find({ list, company, project, state: stateFilter, ...(categoryFilter ? { category: categoryFilter } : {}) }, {
235
+ projection: { _id: 1, user: 1, project: 1, category: 1, state: 1, ...answerProjection },
236
+ })
237
+ .sort({ _id: 1 })
238
+ .toArray();
239
+ if (!entries.length)
240
+ return [];
241
+ const customerIds = [...new Map(entries.map((entry) => [entry.user.toHexString(), entry.user])).values()];
242
+ const customers = await CustomersService.peopleForPlanning(customerIds, company, {
243
+ _id: 1, id: 1, name: 1, prename: 1, email: 1, ...answerProjection,
244
+ });
245
+ const term = search?.trim().toLowerCase();
246
+ const matchingCustomers = term
247
+ ? customers.filter((customer) => [customer.name, customer.prename].filter(Boolean).join(', ').toLowerCase().includes(term))
248
+ : customers;
249
+ const matchingCustomerIds = term ? new Set(matchingCustomers.map((customer) => customer._id.toHexString())) : undefined;
250
+ const matchingEntries = matchingCustomerIds ? entries.filter((entry) => matchingCustomerIds.has(entry.user.toHexString())) : entries;
251
+ if (!matchingEntries.length)
252
+ return [];
253
+ return this.scoreEntries(matchingEntries, matchingCustomers, conditions, scoring.list, scoring.customer);
254
+ }
255
+ /**
256
+ * Shared reads and ranked candidate pools for a planning scope. The list, its structures, the
257
+ * customer structures and every used work area are read once, and all condition answers and
258
+ * categories are fetched in a single entries query; ranking then happens in memory per shift.
259
+ * Coverage and autofill both build on this, so a scope resolves to the same targets either way.
260
+ */
261
+ async prepareShiftPlanning(list, scope, options) {
262
+ const company = options.company;
263
+ const project = options.project;
264
+ const planId = scope.plan ?? (await ListsService.singleForPlanning(list, options))?.shiftPlan;
265
+ if (!planId)
266
+ return undefined;
267
+ const plan = await ShiftPlanModel.db.findOne({ _id: planId, company, project });
268
+ if (!plan)
269
+ return undefined;
270
+ const shifts = await ShiftModel.db.find({ company, project, plan: plan._id }).sort({ start: 1, _id: 1 }).toArray();
271
+ // A work area or a single shift narrows the targets; the plan's full shifts stay the conflict scope.
272
+ const reportShifts = scope.shift ? shifts.filter((shift) => shift._id.equals(scope.shift))
273
+ : scope.workArea ? shifts.filter((shift) => shift.area.equals(scope.workArea))
274
+ : scope.day ? shifts.filter((shift) => shift.day.equals(scope.day))
275
+ : shifts;
276
+ const participantIds = shifts.flatMap((shift) => shift.assignments.map((assignment) => assignment.participant));
277
+ const customerIds = shifts.flatMap((shift) => shift.assignments.map((assignment) => assignment.customer));
278
+ if (!reportShifts.length)
279
+ return { plan, shifts, targets: [], participantIds, customerIds, settings: null, listStructures: [], customerStructures: [] };
280
+ const settings = await SettingsService.singleSettings(options);
281
+ if (!effectivePolicy(settings?.settings, plan.settingsOverrides))
282
+ throw new ImmutableError('SHIFT_PLANNING_SETTINGS_NOT_CONFIGURED');
283
+ const [listDocument, listStructures, customerStructures, areas] = await Promise.all([
284
+ ListsService.singleForPlanning(list, options),
285
+ StructureService.getByModel('listsParticipants', { company, project }),
286
+ StructureService.byModelAndCompany('customers', company),
287
+ AreaModel.db.find({ _id: { $in: uniqueObjectIds(reportShifts.map((shift) => shift.area)) }, company, project }).toArray(),
288
+ ]);
289
+ const areaStructuresById = new Map(areas.map((area) => [area._id.toHexString(), area.structures ?? []]));
290
+ const categoryStringById = new Map((listDocument?.categories ?? []).map((category) => [category._id.toHexString(), category.id]));
291
+ const bindings = plan.workAreaCategories ?? [];
292
+ const prepared = reportShifts.map((shift) => {
293
+ const conditions = shift.structures?.length ? shift.structures : (areaStructuresById.get(shift.area.toHexString()) ?? []);
294
+ // A binding carries list category `_id`s; participants store the categories' `id`.
295
+ const binding = bindings.find((entry) => entry.area.equals(shift.area))?.categoryIds;
296
+ const categoryStrings = binding?.length
297
+ ? new Set(binding.map((id) => categoryStringById.get(id.toHexString())).filter((id) => id !== undefined))
298
+ : null;
299
+ return { shift, conditions, categoryStrings };
300
+ });
301
+ const usedStructures = this.scoringStructures(prepared.flatMap((entry) => entry.conditions), listStructures, customerStructures);
302
+ const answerProjection = Object.fromEntries([...usedStructures.list, ...usedStructures.customer].map((structure) => [structure.id, 1]));
303
+ const anyUnbound = prepared.some((entry) => entry.categoryStrings === null);
304
+ const boundCategories = new Set();
305
+ for (const entry of prepared)
306
+ if (entry.categoryStrings)
307
+ for (const id of entry.categoryStrings)
308
+ boundCategories.add(id);
309
+ const state = plan.defaultParticipantState ? [plan.defaultParticipantState] : undefined;
310
+ const stateFilter = state?.length
311
+ ? { $in: state }
312
+ : { $nin: ['blocked', 'unchecked', 'notuseful'] };
313
+ const entries = await this.model.db
314
+ .find({ list, company, project, state: stateFilter, ...(anyUnbound ? {} : { category: { $in: [...boundCategories] } }) }, {
315
+ projection: { _id: 1, user: 1, project: 1, category: 1, state: 1, ...answerProjection },
316
+ })
317
+ .sort({ _id: 1 })
318
+ .toArray();
319
+ const resolvedCustomers = entries.length
320
+ ? await CustomersService.peopleForPlanning(uniqueObjectIds(entries.map((entry) => entry.user)), company, {
321
+ _id: 1, id: 1, name: 1, prename: 1, email: 1, ...answerProjection,
322
+ })
323
+ : [];
324
+ const targets = prepared.map((entry) => {
325
+ const matching = entry.categoryStrings
326
+ ? entries.filter((candidate) => candidate.category !== undefined && entry.categoryStrings.has(candidate.category))
327
+ : entries;
328
+ const scoring = this.scoringStructures(entry.conditions, listStructures, customerStructures);
329
+ // Cancelled entries are still returned for the shift editor; coverage only counts usable people.
330
+ const ranked = this.scoreEntries(matching, resolvedCustomers, entry.conditions, scoring.list, scoring.customer)
331
+ .filter((candidate) => !unavailableParticipantStates.has(candidate.state));
332
+ participantIds.push(...ranked.map((candidate) => candidate._id));
333
+ customerIds.push(...ranked.flatMap((candidate) => candidate.user?._id ? [candidate.user._id] : []));
334
+ return {
335
+ shift: entry.shift,
336
+ conditions: entry.conditions,
337
+ candidates: ranked.flatMap((candidate) => candidate.user?._id
338
+ ? [{ participant: candidate._id, customer: candidate.user._id, matching: candidate.match.state === 'fulfilled' }]
339
+ : []),
340
+ };
341
+ });
342
+ return { plan, shifts, targets, participantIds, customerIds, settings, listStructures, customerStructures };
343
+ }
344
+ /** Diagnostic data shared by a coverage run; the schedule is supplied per allocation step. */
345
+ async coverageReportContext(preparation, options) {
346
+ const participantIds = uniqueObjectIds(preparation.participantIds);
347
+ const customerIds = uniqueObjectIds(preparation.customerIds);
348
+ const starts = preparation.shifts.map((shift) => Date.parse(shift.start));
349
+ const ends = preparation.shifts.map((shift) => Date.parse(shift.end));
350
+ const [people, absences] = await Promise.all([
351
+ this.planningParticipantContext(participantIds, customerIds, options.company, preparation),
352
+ AbsencesService.absencesForPlanning(customerIds, boundedPlanningInstant(Math.min(...starts)), boundedPlanningInstant(Math.max(...ends)), options),
353
+ ]);
354
+ return { plans: [preparation.plan], settings: preparation.settings?.settings, absences, ...people };
355
+ }
356
+ /**
357
+ * Coverage of every shift in the linked (or given) plan or day. Only the plan's own shifts are
358
+ * compared, so collisions with other plans are out of scope.
359
+ */
360
+ async planCoverage(list, scope, options, coverageMode) {
361
+ const preparation = await this.prepareShiftPlanning(list, scope, options);
362
+ if (!preparation)
363
+ return undefined;
364
+ const level = scope.day ? 'day' : 'plan';
365
+ if (!preparation.targets.length)
366
+ return { level, plan: preparation.plan._id, shifts: [], totalShifts: 0, fillableShifts: 0, fillable: true };
367
+ const context = await this.coverageReportContext(preparation, options);
368
+ const allocation = allocateShiftCoverage(preparation.shifts, preparation.targets, context, false, coverageMode);
369
+ const fillableShifts = allocation.coverage.filter((entry) => entry.fillable).length;
370
+ return { level, plan: preparation.plan._id, shifts: allocation.coverage, totalShifts: allocation.coverage.length, fillableShifts, fillable: allocation.fillable };
371
+ }
372
+ /**
373
+ * Cheap answer for the same coverage: whether every shift in the plan can be staffed. Allocation
374
+ * stops at the first shift that cannot be filled, so no per-shift detail is produced.
375
+ */
376
+ async planCoverageSummary(list, scope, options, coverageMode = 'matching') {
377
+ const preparation = await this.prepareShiftPlanning(list, scope, options);
378
+ if (!preparation)
379
+ return { fillable: false };
380
+ if (!preparation.targets.length)
381
+ return { fillable: true };
382
+ const context = await this.coverageReportContext(preparation, options);
383
+ return { fillable: allocateShiftCoverage(preparation.shifts, preparation.targets, context, true, coverageMode).fillable };
384
+ }
19
385
  constructor(connectionString, accountName, queue) {
20
386
  super();
21
387
  this.model = ListParticipantsModel;
@@ -33,6 +399,8 @@ export class ListParticipantsService extends Immutable {
33
399
  ],
34
400
  },
35
401
  },
402
+ // The customer's own structure answers and their definitions travel with the participant.
403
+ ...companyStructuresAggregation,
36
404
  ],
37
405
  },
38
406
  },
@@ -45,10 +413,25 @@ export class ListParticipantsService extends Immutable {
45
413
  {
46
414
  $addFields: {
47
415
  'user': {
48
- name: { $concat: ['$userData.name', ', ', '$userData.prename'] },
49
- email: '$userData.email',
50
- id: '$userData.id',
51
- _id: '$userData._id',
416
+ $mergeObjects: [
417
+ {
418
+ name: { $concat: ['$userData.name', ', ', '$userData.prename'] },
419
+ email: '$userData.email',
420
+ id: '$userData.id',
421
+ _id: '$userData._id',
422
+ },
423
+ // Carry the customer's raw s- answers so shift conditions can be evaluated against them.
424
+ {
425
+ $arrayToObject: {
426
+ $filter: {
427
+ input: { $objectToArray: { $ifNull: ['$userData', {}] } },
428
+ as: 'entry',
429
+ cond: { $eq: [{ $substrCP: ['$$entry.k', 0, 2] }, 's-'] },
430
+ },
431
+ },
432
+ },
433
+ { structures: { $ifNull: ['$userData.structures', []] } },
434
+ ],
52
435
  },
53
436
  },
54
437
  },
@@ -693,6 +1076,20 @@ export class ListParticipantsService extends Immutable {
693
1076
  await this.immutable({ _id: participant._id, list, company: options.company, project: options.project }, structureInput, options.user, 'change', options.app, undefined, undefined, 'changeMemberEntry', { origin: 'member', validator, unset });
694
1077
  return this.singleWithoutLookup(list, participant.user, options);
695
1078
  }
1079
+ async updateStructures(list, participationId, structures, options) {
1080
+ const baseSchema = ListParticipantsModel.getSchema('changeStructures');
1081
+ if (!baseSchema)
1082
+ throw new ImmutableError('VALIDATION_FAILED');
1083
+ const structureDefinitions = await StructureService.getByModel('listsParticipants', options);
1084
+ const validator = StructureService.createValidator([], [], structureDefinitions, hardCopy(baseSchema.schema));
1085
+ if (!validator(structures))
1086
+ throw new ImmutableError('VALIDATION_FAILED', validator.errors);
1087
+ const payload = (structures ?? {});
1088
+ const structureIds = structureDefinitions.map((structure) => structure.id).filter((id) => Boolean(id));
1089
+ // a structure is only kept when the payload carries a key for it, so explicit false/0 answers survive
1090
+ const unset = structureIds.filter((key) => !(key in payload));
1091
+ return this.immutable({ _id: participationId, list, company: options.company, project: options.project }, structures, options.user, 'change', options.app, undefined, undefined, 'changeStructures', { validator, unset });
1092
+ }
696
1093
  async note(list, participationId, note, options) {
697
1094
  return this.immutable({ _id: participationId, list, company: options.company, project: options.project }, note ? { note } : {}, options.user, 'change', options.app, undefined, undefined, 'updateNote');
698
1095
  }
@@ -801,6 +1198,28 @@ export class ListParticipantsService extends Immutable {
801
1198
  query.aggregation.push(...this.customerLookup);
802
1199
  return HistoryService.listCustomQuery(query);
803
1200
  }
1201
+ /** a history entry of this list and the version before it, with the structures that name the custom fields */
1202
+ async historyEntry(list, historyId, options) {
1203
+ const entry = await HistoryService.entryWithPrevious(options.company, options.project, historyId, { list, 'history.model': 'listParticipants' });
1204
+ if (!entry)
1205
+ return null;
1206
+ return { ...entry, structures: await StructureService.getByModel('listsParticipants', options) };
1207
+ }
1208
+ historySeenKey(list, options) {
1209
+ return {
1210
+ user: options.user,
1211
+ company: options.company,
1212
+ project: options.project,
1213
+ model: 'listParticipants',
1214
+ scope: list.toString(),
1215
+ };
1216
+ }
1217
+ historyUnreadCount(list, options) {
1218
+ return HistorySeenService.unreadCount(this.historySeenKey(list, options), { list });
1219
+ }
1220
+ markHistorySeen(list, options) {
1221
+ return HistorySeenService.markSeen(this.historySeenKey(list, options), { list });
1222
+ }
804
1223
  agreementsMissing(listAgreements, providedAgreements) {
805
1224
  const missing = [];
806
1225
  const required = listAgreements?.filter((single) => single.required);
@@ -842,6 +1261,7 @@ export class ListParticipantsService extends Immutable {
842
1261
  ...aggregation,
843
1262
  ...this.getCustomerLookup(lastname),
844
1263
  ...this.paymentLookupList,
1264
+ ...projectStructuresAggregation,
845
1265
  ];
846
1266
  aggregation.push({
847
1267
  $addFields: {