forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPlanningCapabilities.cs
More file actions
475 lines (403 loc) · 17.2 KB
/
Copy pathPlanningCapabilities.cs
File metadata and controls
475 lines (403 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
using FlowX;
namespace Crm;
/// <summary>
/// Declares a period that plans are made for.
/// </summary>
/// <remarks>
/// <strong>A child period is checked to sit inside its parent.</strong> A quarter that sticks out
/// of its year makes a roll-up that counts something twice or loses it, and neither shows up as an
/// error — only as a total nobody can reconcile, three weeks later, in a board pack.
/// </remarks>
[Capability("crm.planning.period", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.admin")]
public sealed class DefinePlanPeriod : ICapability<DefinePeriod, PeriodDefined>
{
private readonly PlanningStore _planning;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Writes the period.</param>
/// <exception cref="ArgumentNullException"><paramref name="planning"/> is null.</exception>
public DefinePlanPeriod(PlanningStore planning)
{
ArgumentNullException.ThrowIfNull(planning);
_planning = planning;
}
/// <inheritdoc />
public async ValueTask<Result<PeriodDefined>> ExecuteAsync(
DefinePeriod input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (!CustomValues.IsUsableName(input.Name))
{
return Result.Fail<PeriodDefined>(CustomSchemaErrors.NameIsNotUsable(input.Name));
}
if (input.EndsOn < input.StartsOn)
{
return Result.Fail<PeriodDefined>(PlanningErrors.PeriodEndsBeforeItStarts());
}
Guid? parent = null;
if (input.Parent is { Length: > 0 } named)
{
if (await _planning.PeriodAsync(ctx.TenantId, named, ct).ConfigureAwait(false)
is not { } outer)
{
return Result.Fail<PeriodDefined>(PlanningErrors.PeriodNotFound(named));
}
if (input.StartsOn < outer.StartsOn || input.EndsOn > outer.EndsOn)
{
return Result.Fail<PeriodDefined>(
PlanningErrors.PeriodIsNotInsideItsParent(input.Name, named));
}
parent = outer.PeriodId;
}
var id = await _planning
.SavePeriodAsync(ctx.TenantId, ctx.NewId(), input, parent, ctx.UtcNow, ct)
.ConfigureAwait(false);
return id is { } saved
? Result.Ok(new PeriodDefined(saved))
: Result.Fail<PeriodDefined>(CustomSchemaErrors.NameIsTaken(input.Name));
}
}
/// <summary>
/// Sets the number and the words a period is planned against.
/// </summary>
/// <remarks>
/// <strong>One per period, and setting it again replaces it.</strong> A table of every number a
/// leadership team has ever said would need a rule about which one is current, and that rule is
/// the one somebody gets wrong in the week before a board meeting.
/// </remarks>
[Capability("crm.planning.strategy", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.admin")]
public sealed class SetSalesStrategy : ICapability<SetStrategy, StrategySet>
{
private readonly PlanningStore _planning;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Writes the strategy.</param>
/// <exception cref="ArgumentNullException"><paramref name="planning"/> is null.</exception>
public SetSalesStrategy(PlanningStore planning)
{
ArgumentNullException.ThrowIfNull(planning);
_planning = planning;
}
/// <inheritdoc />
public async ValueTask<Result<StrategySet>> ExecuteAsync(
SetStrategy input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (input.Target < 0)
{
return Result.Fail<StrategySet>(PlanningErrors.TargetIsNegative());
}
if (await _planning.PeriodAsync(ctx.TenantId, input.Period, ct).ConfigureAwait(false)
is not { } period)
{
return Result.Fail<StrategySet>(PlanningErrors.PeriodNotFound(input.Period));
}
var id = await _planning
.SaveStrategyAsync(ctx.TenantId, ctx.NewId(), period.PeriodId, input, ctx.UtcNow, ct)
.ConfigureAwait(false);
return Result.Ok(new StrategySet(id));
}
}
/// <summary>
/// Commits one plan against a period.
/// </summary>
/// <remarks>
/// <para>
/// <strong>What each kind must carry, and must not, is checked here as well as by the table.</strong>
/// A marketing plan with a money target and no lead target is a row that would roll up into the
/// revenue number twice — once as a commitment and once as the pipeline it was meant to create.
/// </para>
/// <para>
/// <strong>A channel is checked against the five a lead can arrive from.</strong> A plan for a
/// sixth is a plan nothing can ever be counted against, so it reports zero for ever and reads as a
/// marketing failure rather than as a typo.
/// </para>
/// </remarks>
[Capability("crm.planning.plan", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.write")]
public sealed class DefineCrmPlan : ICapability<DefinePlan, PlanDefined>
{
private readonly PlanningStore _planning;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Writes the plan.</param>
/// <exception cref="ArgumentNullException"><paramref name="planning"/> is null.</exception>
public DefineCrmPlan(PlanningStore planning)
{
ArgumentNullException.ThrowIfNull(planning);
_planning = planning;
}
/// <inheritdoc />
public async ValueTask<Result<PlanDefined>> ExecuteAsync(
DefinePlan input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (!CustomValues.IsUsableName(input.Name))
{
return Result.Fail<PlanDefined>(CustomSchemaErrors.NameIsNotUsable(input.Name));
}
if (Shape(input) is { } wrong)
{
return Result.Fail<PlanDefined>(wrong);
}
if (await _planning.PeriodAsync(ctx.TenantId, input.Period, ct).ConfigureAwait(false)
is not { } period)
{
return Result.Fail<PlanDefined>(PlanningErrors.PeriodNotFound(input.Period));
}
Guid? parent = null;
if (input.Parent is { Length: > 0 } named)
{
if (await _planning.PlanIdAsync(ctx.TenantId, named, ct).ConfigureAwait(false)
is not { } above)
{
return Result.Fail<PlanDefined>(PlanningErrors.PlanNotFound(named));
}
// A plan that rolls up into its own descendant makes every total above it either wrong
// or non-terminating, and a reorganisation is exactly where that gets typed in.
if (await _planning.TreeWouldLoopAsync(ctx.TenantId, input.Name, above, ct)
.ConfigureAwait(false))
{
return Result.Fail<PlanDefined>(PlanningErrors.PlanTreeWouldLoop(input.Name));
}
parent = above;
}
var id = await _planning
.SavePlanAsync(ctx.TenantId, ctx.NewId(), period.PeriodId, input, parent, ctx.UtcNow, ct)
.ConfigureAwait(false);
return id is { } saved
? Result.Ok(new PlanDefined(saved))
: Result.Fail<PlanDefined>(CustomSchemaErrors.NameIsTaken(input.Name));
}
private static Error? Shape(DefinePlan input)
{
var money = input.Kind
is PlanKind.Account or PlanKind.Opportunity or PlanKind.Portfolio;
if ((input.Account is not null) != (input.Kind == PlanKind.Account))
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.Account));
}
if ((input.Opportunity is not null) != (input.Kind == PlanKind.Opportunity))
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.Opportunity));
}
if ((input.Channel is not null) != (input.Kind == PlanKind.MarketingLead))
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.Channel));
}
if ((input.TargetLeads is not null) != (input.Kind == PlanKind.MarketingLead))
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.TargetLeads));
}
if ((input.TargetAmount is not null) != money)
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.TargetAmount));
}
if ((input.Currency is not null) != money)
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.Currency));
}
if ((input.ActivityKind is not null) != (input.Kind == PlanKind.Operation))
{
return PlanningErrors.KindAndFieldsDisagree(input.Kind, nameof(DefinePlan.ActivityKind));
}
if ((input.TargetActivities is not null) != (input.Kind == PlanKind.Operation))
{
return PlanningErrors.KindAndFieldsDisagree(
input.Kind, nameof(DefinePlan.TargetActivities));
}
if (input.Channel is { } channel
&& !PlanningLimits.Channels.Contains(channel, StringComparer.Ordinal))
{
return PlanningErrors.ChannelIsNotALeadSource(channel);
}
if (input.ActivityKind is { } activity
&& !PlanningLimits.Activities.Contains(activity, StringComparer.Ordinal))
{
return PlanningErrors.ActivityKindIsUnknown(activity);
}
return input.TargetAmount < 0 || input.TargetLeads < 0 || input.TargetActivities < 0
? PlanningErrors.TargetIsNegative()
: null;
}
}
/// <summary>
/// Records whether one thing about a deal is actually known.
/// </summary>
[Capability("crm.planning.qualification", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.write")]
public sealed class RecordQualification : ICapability<AnswerQualification, QualificationRecorded>
{
private readonly PlanningStore _planning;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Writes the answer.</param>
/// <exception cref="ArgumentNullException"><paramref name="planning"/> is null.</exception>
public RecordQualification(PlanningStore planning)
{
ArgumentNullException.ThrowIfNull(planning);
_planning = planning;
}
/// <inheritdoc />
public async ValueTask<Result<QualificationRecorded>> ExecuteAsync(
AnswerQualification input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
var answered = await _planning
.AnswerAsync(ctx.TenantId, input.Plan, input, ct)
.ConfigureAwait(false);
return answered is { } count
? Result.Ok(new QualificationRecorded(count, PlanningLimits.Elements))
: Result.Fail<QualificationRecorded>(PlanningErrors.PlanNotFound(input.Plan));
}
}
/// <summary>
/// Writes a step of the mutual action plan, or marks one done.
/// </summary>
[Capability("crm.planning.step", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.write")]
public sealed class SetCrmPlanStep : ICapability<SetPlanStep, PlanStepSet>
{
private readonly PlanningStore _planning;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Writes the step.</param>
/// <exception cref="ArgumentNullException"><paramref name="planning"/> is null.</exception>
public SetCrmPlanStep(PlanningStore planning)
{
ArgumentNullException.ThrowIfNull(planning);
_planning = planning;
}
/// <inheritdoc />
public async ValueTask<Result<PlanStepSet>> ExecuteAsync(
SetPlanStep input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
var outstanding = await _planning
.SaveStepAsync(ctx.TenantId, input, ctx.UtcNow, ct)
.ConfigureAwait(false);
return outstanding is { } count
? Result.Ok(new PlanStepSet(input.Ordinal, count))
: Result.Fail<PlanStepSet>(PlanningErrors.PlanNotFound(input.Plan));
}
}
/// <summary>
/// Says how a period is looking: the number, what was committed, and the difference.
/// </summary>
/// <remarks>
/// <para>
/// <strong>The gap is the point.</strong> Target minus committed, positive when the plans do not
/// add up to the ambition. Nothing here closes it: there is no reconciling adjustment in the
/// schema and no scaling of children to make a parent add up, because a planning tool that
/// balanced itself would be one that told a board the number was covered when it was not.
/// </para>
/// <para>
/// <strong>Refused when no strategy has been set</strong>, rather than answered with a target of
/// zero — which would show every commitment covering nothing and a gap of minus everything, and
/// read as good news.
/// </para>
/// </remarks>
[Capability("crm.planning.rollup", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class ReadPeriodRollUp : ICapability<ForViewer, PeriodRollUp>
{
private readonly PlanningStore _planning;
private readonly ManagementStore _org;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Reads the plans and the live actuals.</param>
/// <param name="org">Says whose plans this caller sees.</param>
/// <exception cref="ArgumentNullException">Any argument is null.</exception>
public ReadPeriodRollUp(PlanningStore planning, ManagementStore org)
{
ArgumentNullException.ThrowIfNull(planning);
ArgumentNullException.ThrowIfNull(org);
_planning = planning;
_org = org;
}
/// <inheritdoc />
public async ValueTask<Result<PeriodRollUp>> ExecuteAsync(
ForViewer input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (await _planning.PeriodAsync(ctx.TenantId, input.Period, ct).ConfigureAwait(false)
is not { } period)
{
return Result.Fail<PeriodRollUp>(PlanningErrors.PeriodNotFound(input.Period));
}
// Whose plans are in the total. Refused when the caller has not been placed, rather than
// defaulted to their own: a director whose row was never written would otherwise see one
// plan and conclude their organisation had stopped selling.
if (await _org.ScopeAsync(ctx.TenantId, input.UserId, ct).ConfigureAwait(false)
is not { } viewer)
{
return Result.Fail<PeriodRollUp>(ManagementErrors.CallerIsNotInTheOrganisation());
}
var rollUp = await _planning
.RollUpAsync(
ctx.TenantId, period, DateOnly.FromDateTime(ctx.UtcNow.UtcDateTime),
viewer.Scope, ct)
.ConfigureAwait(false);
return rollUp is { } found
? Result.Ok(found)
: Result.Fail<PeriodRollUp>(PlanningErrors.StrategyNotSet(input.Period));
}
}
/// <summary>
/// Says which periods this tenant has declared.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Readable by anybody who can read, not only by an administrator.</strong> Declaring a
/// period is an administrative act; knowing which quarter you are looking at is not. Behind
/// <c>crm.admin</c> this would leave every seller's screen with no period to ask for and no way to
/// find one.
/// </para>
/// <para>
/// <strong>Empty is an answer, not a refusal.</strong> A tenant that has declared no periods yet
/// has nothing to plan against, and saying so once is what lets a screen explain itself instead of
/// showing a not-found for a quarter the client invented.
/// </para>
/// </remarks>
[Capability("crm.planning.periods", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class ReadDeclaredPeriods : ICapability<ReadPeriods, DeclaredPeriods>
{
private readonly PlanningStore _planning;
/// <summary>Creates the capability.</summary>
/// <param name="planning">Reads the periods.</param>
/// <exception cref="ArgumentNullException"><paramref name="planning"/> is null.</exception>
public ReadDeclaredPeriods(PlanningStore planning)
{
ArgumentNullException.ThrowIfNull(planning);
_planning = planning;
}
/// <inheritdoc />
public async ValueTask<Result<DeclaredPeriods>> ExecuteAsync(
ReadPeriods input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(ctx);
var periods = await _planning
.PeriodsAsync(ctx.TenantId, DateOnly.FromDateTime(ctx.UtcNow.UtcDateTime), ct)
.ConfigureAwait(false);
return Result.Ok(new DeclaredPeriods(periods));
}
}