@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.
- package/lib/models.d.ts +8 -1
- package/lib/models.js +8 -1
- package/lib/models.js.map +1 -1
- package/lib/services.d.ts +8 -1
- package/lib/services.js +8 -1
- package/lib/services.js.map +1 -1
- package/lib/src/Immutable.class.js +10 -4
- package/lib/src/Immutable.class.js.map +1 -1
- package/lib/src/aggegations/structures.aggregation.d.ts +6 -8
- package/lib/src/aggegations/structures.aggregation.js +3 -2
- package/lib/src/aggegations/structures.aggregation.js.map +1 -1
- package/lib/src/classes/paymentProviders/PaymentProviderFactory.class.d.ts +4 -0
- package/lib/src/classes/paymentProviders/PaymentProviderFactory.class.js +8 -0
- package/lib/src/classes/paymentProviders/PaymentProviderFactory.class.js.map +1 -0
- package/lib/src/classes/paymentProviders/StripePaymentProvider.class.d.ts +9 -0
- package/lib/src/classes/paymentProviders/StripePaymentProvider.class.js +49 -0
- package/lib/src/classes/paymentProviders/StripePaymentProvider.class.js.map +1 -0
- package/lib/src/classes/shiftPlanningModel.class.d.ts +6 -0
- package/lib/src/classes/shiftPlanningModel.class.js +26 -0
- package/lib/src/classes/shiftPlanningModel.class.js.map +1 -0
- package/lib/src/helpers/buildProductName.d.ts +7 -0
- package/lib/src/helpers/buildProductName.js +20 -0
- package/lib/src/helpers/buildProductName.js.map +1 -0
- package/lib/src/helpers/shiftPlanAllocation.d.ts +53 -0
- package/lib/src/helpers/shiftPlanAllocation.js +110 -0
- package/lib/src/helpers/shiftPlanAllocation.js.map +1 -0
- package/lib/src/helpers/shiftPlanCoverage.d.ts +2 -0
- package/lib/src/helpers/shiftPlanCoverage.js +4 -0
- package/lib/src/helpers/shiftPlanCoverage.js.map +1 -0
- package/lib/src/helpers/shiftPlanningReports.d.ts +7 -0
- package/lib/src/helpers/shiftPlanningReports.js +135 -0
- package/lib/src/helpers/shiftPlanningReports.js.map +1 -0
- package/lib/src/helpers/shiftPlanningSchemas.d.ts +143 -0
- package/lib/src/helpers/shiftPlanningSchemas.js +87 -0
- package/lib/src/helpers/shiftPlanningSchemas.js.map +1 -0
- package/lib/src/helpers/shiftPlanningTime.d.ts +24 -0
- package/lib/src/helpers/shiftPlanningTime.js +215 -0
- package/lib/src/helpers/shiftPlanningTime.js.map +1 -0
- package/lib/src/helpers/shiftPlanningValidation.d.ts +32 -0
- package/lib/src/helpers/shiftPlanningValidation.js +69 -0
- package/lib/src/helpers/shiftPlanningValidation.js.map +1 -0
- package/lib/src/helpers/structureMatch.d.ts +15 -0
- package/lib/src/helpers/structureMatch.js +72 -0
- package/lib/src/helpers/structureMatch.js.map +1 -0
- package/lib/src/interfaces/ListPlanningParticipant.d.ts +17 -0
- package/lib/src/interfaces/ListPlanningParticipant.js +2 -0
- package/lib/src/interfaces/ListPlanningParticipant.js.map +1 -0
- package/lib/src/interfaces/PaymentProviderBase.d.ts +14 -0
- package/lib/src/interfaces/PaymentProviderBase.js +2 -0
- package/lib/src/interfaces/PaymentProviderBase.js.map +1 -0
- package/lib/src/interfaces/PlanCoveragePreparation.d.ts +17 -0
- package/lib/src/interfaces/PlanCoveragePreparation.js +2 -0
- package/lib/src/interfaces/PlanCoveragePreparation.js.map +1 -0
- package/lib/src/interfaces/PlanningParticipantsResult.d.ts +12 -0
- package/lib/src/interfaces/PlanningParticipantsResult.js +2 -0
- package/lib/src/interfaces/PlanningParticipantsResult.js.map +1 -0
- package/lib/src/interfaces/ShiftAutofillResult.d.ts +6 -0
- package/lib/src/interfaces/ShiftAutofillResult.js +2 -0
- package/lib/src/interfaces/ShiftAutofillResult.js.map +1 -0
- package/lib/src/interfaces/ShiftReportContextOptions.d.ts +9 -0
- package/lib/src/interfaces/ShiftReportContextOptions.js +2 -0
- package/lib/src/interfaces/ShiftReportContextOptions.js.map +1 -0
- package/lib/src/models/customers.model.js +10 -0
- package/lib/src/models/customers.model.js.map +1 -1
- package/lib/src/models/employeeAbsence.model.d.ts +4 -0
- package/lib/src/models/employeeAbsence.model.js +11 -0
- package/lib/src/models/employeeAbsence.model.js.map +1 -0
- package/lib/src/models/historySeen.model.d.ts +4 -0
- package/lib/src/models/historySeen.model.js +30 -0
- package/lib/src/models/historySeen.model.js.map +1 -0
- package/lib/src/models/lists.model.js +2 -1
- package/lib/src/models/lists.model.js.map +1 -1
- package/lib/src/models/listsParticipants.model.js +11 -0
- package/lib/src/models/listsParticipants.model.js.map +1 -1
- package/lib/src/models/permissions.model.js +28 -0
- package/lib/src/models/permissions.model.js.map +1 -1
- package/lib/src/models/shift.model.d.ts +4 -0
- package/lib/src/models/shift.model.js +17 -0
- package/lib/src/models/shift.model.js.map +1 -0
- package/lib/src/models/shiftPlan.model.d.ts +4 -0
- package/lib/src/models/shiftPlan.model.js +14 -0
- package/lib/src/models/shiftPlan.model.js.map +1 -0
- package/lib/src/models/shiftPlanDays.model.d.ts +4 -0
- package/lib/src/models/shiftPlanDays.model.js +15 -0
- package/lib/src/models/shiftPlanDays.model.js.map +1 -0
- package/lib/src/models/shiftPlanningSettings.model.d.ts +4 -0
- package/lib/src/models/shiftPlanningSettings.model.js +10 -0
- package/lib/src/models/shiftPlanningSettings.model.js.map +1 -0
- package/lib/src/models/workAreas.model.d.ts +4 -0
- package/lib/src/models/workAreas.model.js +12 -0
- package/lib/src/models/workAreas.model.js.map +1 -0
- package/lib/src/services/customers.service.d.ts +12 -1
- package/lib/src/services/customers.service.js +27 -0
- package/lib/src/services/customers.service.js.map +1 -1
- package/lib/src/services/employeeAbsences.service.d.ts +18 -0
- package/lib/src/services/employeeAbsences.service.js +57 -0
- package/lib/src/services/employeeAbsences.service.js.map +1 -0
- package/lib/src/services/history.service.d.ts +15 -0
- package/lib/src/services/history.service.js +36 -0
- package/lib/src/services/history.service.js.map +1 -1
- package/lib/src/services/historySeen.service.d.ts +32 -0
- package/lib/src/services/historySeen.service.js +66 -0
- package/lib/src/services/historySeen.service.js.map +1 -0
- package/lib/src/services/listParticipants.service.d.ts +180 -12
- package/lib/src/services/listParticipants.service.js +426 -6
- package/lib/src/services/listParticipants.service.js.map +1 -1
- package/lib/src/services/lists.service.d.ts +9 -0
- package/lib/src/services/lists.service.js +51 -0
- package/lib/src/services/lists.service.js.map +1 -1
- package/lib/src/services/paymentProviders.service.d.ts +5 -0
- package/lib/src/services/paymentProviders.service.js +10 -0
- package/lib/src/services/paymentProviders.service.js.map +1 -1
- package/lib/src/services/shiftPlanDays.service.d.ts +22 -0
- package/lib/src/services/shiftPlanDays.service.js +80 -0
- package/lib/src/services/shiftPlanDays.service.js.map +1 -0
- package/lib/src/services/shiftPlanningSettings.service.d.ts +11 -0
- package/lib/src/services/shiftPlanningSettings.service.js +27 -0
- package/lib/src/services/shiftPlanningSettings.service.js.map +1 -0
- package/lib/src/services/shiftPlans.service.d.ts +24 -0
- package/lib/src/services/shiftPlans.service.js +139 -0
- package/lib/src/services/shiftPlans.service.js.map +1 -0
- package/lib/src/services/shifts.service.d.ts +46 -0
- package/lib/src/services/shifts.service.js +405 -0
- package/lib/src/services/shifts.service.js.map +1 -0
- package/lib/src/services/structures.service.d.ts +2 -0
- package/lib/src/services/structures.service.js +7 -0
- package/lib/src/services/structures.service.js.map +1 -1
- package/lib/src/services/workAreas.service.d.ts +18 -0
- package/lib/src/services/workAreas.service.js +67 -0
- package/lib/src/services/workAreas.service.js.map +1 -0
- package/lib/tests/buildProductName.spec.d.ts +1 -0
- package/lib/tests/buildProductName.spec.js +27 -0
- package/lib/tests/buildProductName.spec.js.map +1 -0
- package/lib/tests/customersServiceStructures.spec.d.ts +1 -0
- package/lib/tests/customersServiceStructures.spec.js +146 -0
- package/lib/tests/customersServiceStructures.spec.js.map +1 -0
- package/lib/tests/historySeenService.spec.d.ts +1 -0
- package/lib/tests/historySeenService.spec.js +159 -0
- package/lib/tests/historySeenService.spec.js.map +1 -0
- package/lib/tests/listParticipantsPlanning.spec.d.ts +1 -0
- package/lib/tests/listParticipantsPlanning.spec.js +479 -0
- package/lib/tests/listParticipantsPlanning.spec.js.map +1 -0
- package/lib/tests/listParticipantsServiceStructures.spec.d.ts +1 -0
- package/lib/tests/listParticipantsServiceStructures.spec.js +176 -0
- package/lib/tests/listParticipantsServiceStructures.spec.js.map +1 -0
- package/lib/tests/listsServiceCreateExternalProducts.spec.d.ts +1 -0
- package/lib/tests/listsServiceCreateExternalProducts.spec.js +91 -0
- package/lib/tests/listsServiceCreateExternalProducts.spec.js.map +1 -0
- package/lib/tests/paymentProviders.spec.d.ts +1 -0
- package/lib/tests/paymentProviders.spec.js +119 -0
- package/lib/tests/paymentProviders.spec.js.map +1 -0
- package/lib/tests/paymentProvidersServiceGetProduct.spec.d.ts +1 -0
- package/lib/tests/paymentProvidersServiceGetProduct.spec.js +33 -0
- package/lib/tests/paymentProvidersServiceGetProduct.spec.js.map +1 -0
- package/lib/tests/shiftPlanCoverage.spec.d.ts +1 -0
- package/lib/tests/shiftPlanCoverage.spec.js +189 -0
- package/lib/tests/shiftPlanCoverage.spec.js.map +1 -0
- package/lib/tests/shiftPlanning.spec.d.ts +1 -0
- package/lib/tests/shiftPlanning.spec.js +1162 -0
- package/lib/tests/shiftPlanning.spec.js.map +1 -0
- package/lib/tests/shiftPlanningTime.spec.d.ts +1 -0
- package/lib/tests/shiftPlanningTime.spec.js +90 -0
- package/lib/tests/shiftPlanningTime.spec.js.map +1 -0
- package/lib/tests/structureMatch.spec.d.ts +1 -0
- package/lib/tests/structureMatch.spec.js +106 -0
- package/lib/tests/structureMatch.spec.js.map +1 -0
- 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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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: {
|