@mj-biz-apps/sales-core-entities-server 5.1.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/LICENSE +183 -0
- package/dist/CloseDealOperation.d.ts +176 -0
- package/dist/CloseDealOperation.d.ts.map +1 -0
- package/dist/CloseDealOperation.js +1107 -0
- package/dist/CloseDealOperation.js.map +1 -0
- package/dist/CloseWonTaskService.d.ts +249 -0
- package/dist/CloseWonTaskService.d.ts.map +1 -0
- package/dist/CloseWonTaskService.js +460 -0
- package/dist/CloseWonTaskService.js.map +1 -0
- package/dist/DealEntityServer.d.ts +625 -0
- package/dist/DealEntityServer.d.ts.map +1 -0
- package/dist/DealEntityServer.js +1529 -0
- package/dist/DealEntityServer.js.map +1 -0
- package/dist/LiveContractsSeam.d.ts +212 -0
- package/dist/LiveContractsSeam.d.ts.map +1 -0
- package/dist/LiveContractsSeam.js +362 -0
- package/dist/LiveContractsSeam.js.map +1 -0
- package/dist/LiveOrdersSeam.d.ts +68 -0
- package/dist/LiveOrdersSeam.d.ts.map +1 -0
- package/dist/LiveOrdersSeam.js +80 -0
- package/dist/LiveOrdersSeam.js.map +1 -0
- package/dist/SaveDealOperation.d.ts +45 -0
- package/dist/SaveDealOperation.d.ts.map +1 -0
- package/dist/SaveDealOperation.js +268 -0
- package/dist/SaveDealOperation.js.map +1 -0
- package/dist/SequenceService.d.ts +39 -0
- package/dist/SequenceService.d.ts.map +1 -0
- package/dist/SequenceService.js +44 -0
- package/dist/SequenceService.js.map +1 -0
- package/dist/activities/ActivityIngestService.d.ts +111 -0
- package/dist/activities/ActivityIngestService.d.ts.map +1 -0
- package/dist/activities/ActivityIngestService.js +485 -0
- package/dist/activities/ActivityIngestService.js.map +1 -0
- package/dist/activities/ActivityReader.d.ts +68 -0
- package/dist/activities/ActivityReader.d.ts.map +1 -0
- package/dist/activities/ActivityReader.js +147 -0
- package/dist/activities/ActivityReader.js.map +1 -0
- package/dist/activities/ActivitySource.d.ts +142 -0
- package/dist/activities/ActivitySource.d.ts.map +1 -0
- package/dist/activities/ActivitySource.js +2 -0
- package/dist/activities/ActivitySource.js.map +1 -0
- package/dist/activities/ActivitySyncJob.d.ts +72 -0
- package/dist/activities/ActivitySyncJob.d.ts.map +1 -0
- package/dist/activities/ActivitySyncJob.js +146 -0
- package/dist/activities/ActivitySyncJob.js.map +1 -0
- package/dist/activities/ActivityWriterService.d.ts +119 -0
- package/dist/activities/ActivityWriterService.d.ts.map +1 -0
- package/dist/activities/ActivityWriterService.js +270 -0
- package/dist/activities/ActivityWriterService.js.map +1 -0
- package/dist/activities/DealMatcher.d.ts +58 -0
- package/dist/activities/DealMatcher.d.ts.map +1 -0
- package/dist/activities/DealMatcher.js +109 -0
- package/dist/activities/DealMatcher.js.map +1 -0
- package/dist/activities/FixtureActivitySource.d.ts +37 -0
- package/dist/activities/FixtureActivitySource.d.ts.map +1 -0
- package/dist/activities/FixtureActivitySource.js +58 -0
- package/dist/activities/FixtureActivitySource.js.map +1 -0
- package/dist/activities/GraphMessageMapper.d.ts +47 -0
- package/dist/activities/GraphMessageMapper.d.ts.map +1 -0
- package/dist/activities/GraphMessageMapper.js +141 -0
- package/dist/activities/GraphMessageMapper.js.map +1 -0
- package/dist/activities/ImportedGraphActivitySource.d.ts +16 -0
- package/dist/activities/ImportedGraphActivitySource.d.ts.map +1 -0
- package/dist/activities/ImportedGraphActivitySource.js +84 -0
- package/dist/activities/ImportedGraphActivitySource.js.map +1 -0
- package/dist/activities/MSGraphActivitySource.d.ts +67 -0
- package/dist/activities/MSGraphActivitySource.d.ts.map +1 -0
- package/dist/activities/MSGraphActivitySource.js +186 -0
- package/dist/activities/MSGraphActivitySource.js.map +1 -0
- package/dist/activities/MSGraphCalendarSource.d.ts +72 -0
- package/dist/activities/MSGraphCalendarSource.d.ts.map +1 -0
- package/dist/activities/MSGraphCalendarSource.js +225 -0
- package/dist/activities/MSGraphCalendarSource.js.map +1 -0
- package/dist/activities/RelevanceFilter.d.ts +101 -0
- package/dist/activities/RelevanceFilter.d.ts.map +1 -0
- package/dist/activities/RelevanceFilter.js +148 -0
- package/dist/activities/RelevanceFilter.js.map +1 -0
- package/dist/activities/activity-vocabulary.d.ts +103 -0
- package/dist/activities/activity-vocabulary.d.ts.map +1 -0
- package/dist/activities/activity-vocabulary.js +79 -0
- package/dist/activities/activity-vocabulary.js.map +1 -0
- package/dist/forecast/FixtureForecastSource.d.ts +22 -0
- package/dist/forecast/FixtureForecastSource.d.ts.map +1 -0
- package/dist/forecast/FixtureForecastSource.js +16 -0
- package/dist/forecast/FixtureForecastSource.js.map +1 -0
- package/dist/forecast/ForecastSnapshotJob.d.ts +78 -0
- package/dist/forecast/ForecastSnapshotJob.d.ts.map +1 -0
- package/dist/forecast/ForecastSnapshotJob.js +225 -0
- package/dist/forecast/ForecastSnapshotJob.js.map +1 -0
- package/dist/forecast/ForecastSource.d.ts +74 -0
- package/dist/forecast/ForecastSource.d.ts.map +1 -0
- package/dist/forecast/ForecastSource.js +25 -0
- package/dist/forecast/ForecastSource.js.map +1 -0
- package/dist/forecast/QueryForecastSource.d.ts +76 -0
- package/dist/forecast/QueryForecastSource.d.ts.map +1 -0
- package/dist/forecast/QueryForecastSource.js +164 -0
- package/dist/forecast/QueryForecastSource.js.map +1 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/orders-availability.d.ts +15 -0
- package/dist/orders-availability.d.ts.map +1 -0
- package/dist/orders-availability.js +47 -0
- package/dist/orders-availability.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var DealEntityServer_1;
|
|
8
|
+
/**
|
|
9
|
+
* @fileoverview `DealEntityServer` — the rules that need a database, on the one path every write takes.
|
|
10
|
+
*
|
|
11
|
+
* WHAT CHANGED, AND WHY THIS FILE GOT MUCH SMALLER. A deal is not one row, and until MJ v6 this class
|
|
12
|
+
* had to compose the tree itself: transient `Lines` / `PaymentSchedule` arrays, a deletion queue, a
|
|
13
|
+
* re-sequencer, and a `Save()` override that walked header → deletions → children inside an explicit
|
|
14
|
+
* `BeginTransaction` / `CommitTransaction`. All of that is now **Related Record Collections** —
|
|
15
|
+
* declared in metadata, generated onto `mjBizAppsSalesDealEntity`, and executed by MJ's
|
|
16
|
+
* `EntitySavePlan` inside one `EntityTransactionScope`. The framework does header-before-children and
|
|
17
|
+
* deletions-before-inserts because those orderings are inherent to the problem, not to this app.
|
|
18
|
+
*
|
|
19
|
+
* Deleting a hand-rolled implementation in favour of the framework's is only worth it when the
|
|
20
|
+
* framework's is at least as careful. It is: `RelatedRecordCollection.Load()` throws on a failed read
|
|
21
|
+
* AND refuses to load over unsaved children, which is the same "a failed load and an empty collection
|
|
22
|
+
* must never look alike" contract the hand-written `LoadLines` documented — plus a guard against a
|
|
23
|
+
* stray reload discarding work mid-edit, which the hand-written one did not have.
|
|
24
|
+
*
|
|
25
|
+
* WHAT REMAINS HERE IS EXACTLY WHAT NEEDS A DATABASE:
|
|
26
|
+
* - `DealNumber` — a gap-free counter taken from a locked row, inside the save's transaction.
|
|
27
|
+
* - The OWNER STAMP — `Deal.OwnerEmployeeID` derived from the `DealTeamMember` carrying the owner
|
|
28
|
+
* role, resolved from `DealRole.IsOwnerRole` rather than from any role name.
|
|
29
|
+
*
|
|
30
|
+
* WHAT THIS FILE MUST NEVER DO. Arithmetic on money. No summing lines into `Amount`, no applying
|
|
31
|
+
* `RequestedDiscountPct`, no checking that the payment schedule adds up. `Amount` is a cached answer
|
|
32
|
+
* from `Orders.PreviewOrder` carrying its own provenance; the signed figures on a line are
|
|
33
|
+
* transcriptions.
|
|
34
|
+
*
|
|
35
|
+
* The form-shaped rules are NOT here — they are on {@link DealEntity} in `sales-entities`, which this
|
|
36
|
+
* class extends, so they run in the browser as well as on the server. Extending `DealEntity` rather
|
|
37
|
+
* than the generated entity is load-bearing: extend the generated one and every rule in that file
|
|
38
|
+
* silently stops applying on the server.
|
|
39
|
+
*
|
|
40
|
+
* - The CLOSE LOCK (S4). Once the deal's status carries `DealStatusType.LocksDeal = 1`, the header
|
|
41
|
+
* (except Description / NextStep), its lines, its instalments and its team are immutable, and
|
|
42
|
+
* reopening goes through `Sales.ReopenDeal`, which records a reason.
|
|
43
|
+
*
|
|
44
|
+
* ── STILL TO COME (deliberately not stubbed) ────────────────────────────────────────────────────
|
|
45
|
+
* - `DealStageEvent` append on ORDINARY stage transition, stamping `AmountAtTransition` and
|
|
46
|
+
* `ProbabilityAtTransition`. `Sales.CloseDeal` already stamps the CLOSE transition; consolidating
|
|
47
|
+
* the two into a single writer here is D-BD2. It must be inside the graph's transaction, which
|
|
48
|
+
* since MJ `47ff71d68b` means a scope opened around `super.Save()` rather than a graph-node
|
|
49
|
+
* branch — see the note on {@link DealEntityServer.Save}.
|
|
50
|
+
*
|
|
51
|
+
* @module @mj-biz-apps/sales-core-entities-server
|
|
52
|
+
*/
|
|
53
|
+
import { BaseEntity, LogError, } from '@memberjunction/core';
|
|
54
|
+
import { createHash } from 'node:crypto';
|
|
55
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
56
|
+
// ORDERS' OWN RULE, imported rather than restated. `CanTransition` is the same function
|
|
57
|
+
// `OrderEntityServer.passesStatusTransition()` consults, so a refusal here and a refusal there cannot
|
|
58
|
+
// disagree — and the reason string the warning carries is orders' wording, not this app's guess at it.
|
|
59
|
+
import { CanTransition } from '@mj-biz-apps/orders-entities';
|
|
60
|
+
import { DEAL_FIELDS_EDITABLE_WHILE_LOCKED, DealEntity, } from '@mj-biz-apps/sales-entities';
|
|
61
|
+
import { SALES_SCHEMA, getNextDealNumber } from './SequenceService.js';
|
|
62
|
+
const DEAL_ENTITY = 'MJ_BizApps_Sales: Deals';
|
|
63
|
+
const PIPELINE_ENTITY = 'MJ_BizApps_Sales: Pipelines';
|
|
64
|
+
const DEAL_STATUS_ENTITY = 'MJ_BizApps_Sales: Deal Status Types';
|
|
65
|
+
const STAGE_ENTITY = 'MJ_BizApps_Sales: Pipeline Stages';
|
|
66
|
+
const ORDER_HEADER_ENTITY = 'MJ_BizApps_Orders: Order Headers';
|
|
67
|
+
let DealEntityServer = class DealEntityServer extends DealEntity {
|
|
68
|
+
constructor() {
|
|
69
|
+
super(...arguments);
|
|
70
|
+
/* ── The stage → order status writer (S-US5 / S-US7 / S-US8, D-OS1) ─────── */
|
|
71
|
+
/**
|
|
72
|
+
* Warnings from the last `Save()` about what did NOT happen to the order, and why.
|
|
73
|
+
*
|
|
74
|
+
* A channel rather than a thrown error, because that is the whole ruling: *"The Deal stage is the
|
|
75
|
+
* salesperson's record of the sales process; it should never be held hostage by order-side rules"*
|
|
76
|
+
* (Andrew, D-OS1). So a refused order update is not a failure of the save — it is a fact about the
|
|
77
|
+
* save, and something has to be able to say it out loud.
|
|
78
|
+
*
|
|
79
|
+
* `Sales.ReopenDeal` and `Sales.CloseDeal` copy these into their `Issues` with
|
|
80
|
+
* `Severity: 'warning'`, which is how a caller sees them. A plain `deal.Save()` from the board's
|
|
81
|
+
* drag leaves them here and in the server log; see `DECISIONS-NEEDED.md` DN-12, because a warning
|
|
82
|
+
* only a server log carries is one a rep never reads.
|
|
83
|
+
*/
|
|
84
|
+
this._orderStatusWarnings = [];
|
|
85
|
+
/* ── The close lock (L-17, master plan §7.3) ────────────────────────────── */
|
|
86
|
+
/**
|
|
87
|
+
* Set only for the duration of {@link BeginReopen}, which is the ONE audited path through the lock.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately NOT public: a caller that could set this could bypass the lock, which would make the
|
|
90
|
+
* lock advisory. `Sales.ReopenDeal` records a reason; nothing else may get through.
|
|
91
|
+
*/
|
|
92
|
+
this._reopenInProgress = false;
|
|
93
|
+
/**
|
|
94
|
+
* True when THIS save built the deal's order, rather than finding one already there.
|
|
95
|
+
*
|
|
96
|
+
* It exists so the stage's `OrderStatusOnEntry` can be applied to an order that has just come into
|
|
97
|
+
* existence. The status writer otherwise keys on `PipelineStageID` CHANGING, which is right for a
|
|
98
|
+
* move and wrong for a birth: a deal that is already at Proposal when its order is provisioned
|
|
99
|
+
* never moved, so nothing asked the stage what the order should be, and the order stayed Draft
|
|
100
|
+
* while its stage plainly declared Quoted. Found by the story audit against seeded data
|
|
101
|
+
* (`DEAL-9003`), and it would have hit every deal the HubSpot import lands past Proposal.
|
|
102
|
+
*/
|
|
103
|
+
this._orderJustProvisioned = false;
|
|
104
|
+
this._declaredTransition = null;
|
|
105
|
+
this._lastStageEventID = null;
|
|
106
|
+
this._lockedAtSave = false;
|
|
107
|
+
}
|
|
108
|
+
static { DealEntityServer_1 = this; }
|
|
109
|
+
/**
|
|
110
|
+
* Async validation always runs on this path. The base class defaults to skipping it; a deal that
|
|
111
|
+
* carries a whole graph of children is exactly the case where you want it, and silently skipping
|
|
112
|
+
* validation on the one code path every write takes would defeat the point of putting rules here.
|
|
113
|
+
*/
|
|
114
|
+
get DefaultSkipAsyncValidation() {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Prepares the deal, then hands the whole graph to the framework.
|
|
119
|
+
*
|
|
120
|
+
* ── THIS METHOD RUNS EXACTLY ONCE PER SAVE, COMPOSITE OR NOT. ──
|
|
121
|
+
*
|
|
122
|
+
* That is a guarantee of MJ's, not an accident of ours, and it is worth knowing why because it used
|
|
123
|
+
* to be false. When a deal has children, `BaseEntity.Save()` builds a save plan and routes to
|
|
124
|
+
* `saveGraph`, which opens the transaction and executes every node — **the root included** — through
|
|
125
|
+
* the private `BaseEntity.saveAsGraphNode`, which calls `_InnerSave` directly:
|
|
126
|
+
*
|
|
127
|
+
* SaveSelfOnly: (entity, opts) => entity.saveAsGraphNode(opts)
|
|
128
|
+
*
|
|
129
|
+
* `_InnerSave` is BELOW this override, so the graph never re-enters `Save()`. Preparation therefore
|
|
130
|
+
* happens once by construction, on the only call there is, and a childless deal (which never builds
|
|
131
|
+
* a plan at all) takes the same single path.
|
|
132
|
+
*
|
|
133
|
+
* ── DO NOT RE-ADD A GRAPH-NODE GUARD ──
|
|
134
|
+
*
|
|
135
|
+
* Until MJ `47ff71d68b` the graph executed the root by calling this record's public `Save()` again
|
|
136
|
+
* with `EntitySaveOptions.IsGraphNodeSave` set, so this method DID run twice and needed an early
|
|
137
|
+
* return to stay idempotent. MJ deleted that flag deliberately — *"so application code cannot skip
|
|
138
|
+
* companions by passing a public flag"* — and made the node path private. There is no successor
|
|
139
|
+
* property and none is wanted: the re-entrancy the guard defended against no longer happens.
|
|
140
|
+
*
|
|
141
|
+
* `EntitySaveOptions.SkipRelatedCollections` is NOT that successor. It suppresses the caller's own
|
|
142
|
+
* collections on the way in (orders' `OrderEntityServer` uses it to hold its lines back until the
|
|
143
|
+
* header has a key); it says nothing about who is calling. Reaching for it here would skip the
|
|
144
|
+
* deal's lines and team entirely.
|
|
145
|
+
*
|
|
146
|
+
* ── WHERE THE S4 STAGE-EVENT APPEND HAS TO GO (this changed too) ──
|
|
147
|
+
*
|
|
148
|
+
* `DealStageEvent` is append-only provenance, so a rolled-back save must not leave an event behind
|
|
149
|
+
* claiming a transition that did not happen — the append has to be inside the same transaction as
|
|
150
|
+
* the write. The old answer was "put it in the `IsGraphNodeSave` branch", because that branch ran
|
|
151
|
+
* inside the graph's transaction. **That branch is gone, and code placed here now runs BEFORE the
|
|
152
|
+
* graph opens anything.**
|
|
153
|
+
*
|
|
154
|
+
* The mechanism that still works is the one {@link DealEntityServer.saveWithinScope} uses: open a
|
|
155
|
+
* scope with `BeginEntityTransaction()`, do the work, then call `super.Save(options)` inside it. The
|
|
156
|
+
* graph's own scope JOINS an ambient transaction rather than opening a second one, so the append and
|
|
157
|
+
* every row in the graph commit or roll back together.
|
|
158
|
+
*
|
|
159
|
+
* **That append now exists** (D-BD2) and shares its scope with two other jobs. `saveWithinScope` is
|
|
160
|
+
* where the ordering between them is decided and is the only place this class opens a transaction.
|
|
161
|
+
*/
|
|
162
|
+
async Save(options) {
|
|
163
|
+
/**
|
|
164
|
+
* ── A DECLARATION GOVERNS ONE SAVE, AND EVERY EXIT IS A SAVE ENDING ──────────────
|
|
165
|
+
*
|
|
166
|
+
* The clear used to sit near the end of the body, and three guards return BEFORE it: the
|
|
167
|
+
* close-lock refusal, the owner-stamp refusal, and a failure resolving the server-maintained
|
|
168
|
+
* stamps. A caller that declared a transition and was then REFUSED kept the declaration, and
|
|
169
|
+
* the next save of that same object inherited it — stage defaults suppressed, and the previous
|
|
170
|
+
* note stamped onto an unrelated event. `SD40` measured exactly that: the note survived a
|
|
171
|
+
* refusal and landed on the following edit's event.
|
|
172
|
+
*
|
|
173
|
+
* `finally` rather than a fourth clear at each guard, because the bug was a missing clear on a
|
|
174
|
+
* path somebody added later, and a fourth one would only postpone the same mistake to the fifth
|
|
175
|
+
* exit. This shape cannot be got wrong by adding a return.
|
|
176
|
+
*
|
|
177
|
+
* It runs AFTER the body, so the declaration is still standing while `saveWithinScope` consumes
|
|
178
|
+
* it — which is the one ordering constraint here.
|
|
179
|
+
*/
|
|
180
|
+
try {
|
|
181
|
+
return await this.saveDeclared(options);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
this._declaredTransition = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async saveDeclared(options) {
|
|
188
|
+
// Per-save, so a caller reading them after `Save()` sees this save's warnings and not the last
|
|
189
|
+
// one's. Cleared before the close lock can return early — an abandoned save has no warnings.
|
|
190
|
+
this._orderStatusWarnings.length = 0;
|
|
191
|
+
this._orderJustProvisioned = false;
|
|
192
|
+
this._lockedAtSave = false;
|
|
193
|
+
this._lastStageEventID = null;
|
|
194
|
+
/**
|
|
195
|
+
* THE CLOSE LOCK (L-17, master plan §7.3) — enforced HERE and nowhere else.
|
|
196
|
+
*
|
|
197
|
+
* Once a deal enters a status where `DealStatusType.LocksDeal = 1` it is the provenance of a
|
|
198
|
+
* contract and an order, and editing it retroactively falsifies both. Putting the check in the
|
|
199
|
+
* entity server rather than the UI is the whole point: an Action, an agent and a raw
|
|
200
|
+
* `BaseEntity.Save()` all hit the same wall.
|
|
201
|
+
*
|
|
202
|
+
* IT RUNS ONCE PER SAVE, and that is now structural rather than something this code arranges:
|
|
203
|
+
* MJ executes graph nodes through a private path that never re-enters this override, so there is
|
|
204
|
+
* exactly one call to be on. A composite deal and a childless one take the same single path.
|
|
205
|
+
*
|
|
206
|
+
* Runs BEFORE any transaction opens: a refused save should cost nothing.
|
|
207
|
+
*/
|
|
208
|
+
if (this.IsSaved && !this._reopenInProgress) {
|
|
209
|
+
const refusal = await this.checkCloseLock();
|
|
210
|
+
if (refusal) {
|
|
211
|
+
LogError(`DealEntityServer.Save refused: ${refusal}`);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* THE OWNER STAMP IS NOT A FIELD ANYONE MAY SET (S-US1: "cannot be edited directly").
|
|
217
|
+
*
|
|
218
|
+
* Refused here, beside the close lock, and for the same reason it is: the UI already hides the
|
|
219
|
+
* field, and a rule that only the UI enforces is not a rule. Runs before the stamps below so a
|
|
220
|
+
* refused save costs nothing.
|
|
221
|
+
*/
|
|
222
|
+
const ownerRefusal = this.ownerStampEditRefusal();
|
|
223
|
+
if (ownerRefusal) {
|
|
224
|
+
LogError(`DealEntityServer.Save refused: ${ownerRefusal}`);
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await this.stampCompanyFromPipeline();
|
|
229
|
+
await this.stampOwnerFromTeam();
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
LogError(`DealEntityServer.Save: could not resolve a server-maintained stamp: ${err}`);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* AFTER the company stamp, deliberately — and NOT AT ALL on a locked deal.
|
|
237
|
+
*
|
|
238
|
+
* The lock check above has already run, so `_lockedAtSave` is trustworthy here. A locked deal
|
|
239
|
+
* that reaches this point is one making a PERMITTED edit (Description, NextStep), and permitted
|
|
240
|
+
* does not mean "and also create an order for it": a closed deal's order is either already there
|
|
241
|
+
* or was never going to be, and minting one now rewrites `Deal.OrderID` on a row whose whole
|
|
242
|
+
* purpose is to be the frozen record of what was agreed.
|
|
243
|
+
*
|
|
244
|
+
* A legacy closed deal therefore keeps its NULL OrderID. That is the honest outcome — SD24 gives
|
|
245
|
+
* it one the moment it is legitimately reopened and saved, which is the path that is allowed to
|
|
246
|
+
* change it.
|
|
247
|
+
*/
|
|
248
|
+
if (!this._lockedAtSave) {
|
|
249
|
+
this.provisionEmbeddedOrder();
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* WHAT THIS STAGE CHANGE ASKS OF THE ORDER (S-US5) — resolved here, applied inside the
|
|
253
|
+
* transaction.
|
|
254
|
+
*
|
|
255
|
+
* BESIDE `provisionEmbeddedOrder()`, not inside it. Provisioning happens once, on creation;
|
|
256
|
+
* this happens on every stage change for the rest of the deal's life. Folding them together
|
|
257
|
+
* would tie a rule about movement to a rule about birth.
|
|
258
|
+
*
|
|
259
|
+
* The READ happens before any transaction opens, for the same reason the close lock does: it is
|
|
260
|
+
* one lookup, and work that might not be needed should not lengthen a critical section.
|
|
261
|
+
*/
|
|
262
|
+
const stageOrder = this._lockedAtSave ? null : await this.planStageOrderStatus();
|
|
263
|
+
/**
|
|
264
|
+
* AND WHAT IT OWES THE APPEND-ONLY LOG — snapshotted here, for the same reason.
|
|
265
|
+
*
|
|
266
|
+
* Two features arrived at this hook within a day of each other: the stage-order writer (D-OS1)
|
|
267
|
+
* and `DealStageEvent` provenance on an ordinary move (D-BD2). They key on the SAME trigger,
|
|
268
|
+
* `PipelineStageID` changing, and each opened its own `BeginEntityTransaction`. Merging them as
|
|
269
|
+
* two sibling scope-openers would have produced a save that took whichever branch was written
|
|
270
|
+
* last and silently skipped the other — the routing line was the merge conflict, and resolving
|
|
271
|
+
* it textually would have shipped exactly that bug.
|
|
272
|
+
*
|
|
273
|
+
* So there is one scope now, and {@link saveWithinScope} owns the ORDERING, which is the part
|
|
274
|
+
* that actually matters and the part neither branch could state alone.
|
|
275
|
+
*/
|
|
276
|
+
const stageMove = this.planStageEvent();
|
|
277
|
+
/**
|
|
278
|
+
* AND WHETHER THE AMOUNT CACHE COULD HAVE MOVED — a DIFFERENT trigger from the other three.
|
|
279
|
+
*
|
|
280
|
+
* The other three key on the stage changing. `Deal.Amount` keys on the ORDER changing, because
|
|
281
|
+
* that is what it caches: a rep adding a line does not touch the stage, and an amount that only
|
|
282
|
+
* refreshed on stage moves would be stale for exactly the action that changes it most.
|
|
283
|
+
*
|
|
284
|
+
* Drift caused by someone else — finance editing the order directly, which S-US5 explicitly
|
|
285
|
+
* allows — is deliberately NOT chased here. That is what `AmountSourceHash` is for: a surface
|
|
286
|
+
* holding the order can recompute the fingerprint and say "stale, reprice". Polling the order on
|
|
287
|
+
* every unrelated deal save would be a read per keystroke for a guarantee the hash already gives.
|
|
288
|
+
*/
|
|
289
|
+
const order = this.OrderID_Object;
|
|
290
|
+
const amountMayHaveMoved = !!order && (order.Dirty || order.Lines.Dirty);
|
|
291
|
+
// The stage's forecast defaults, on the same trigger as the three above. Read here, applied
|
|
292
|
+
// inside the scope, for the same reason the others are: work that might not be needed should not
|
|
293
|
+
// lengthen a critical section.
|
|
294
|
+
/**
|
|
295
|
+
* NEITHER STAGE WRITER RUNS ON A LOCKED DEAL. The stage cannot legitimately move on one — the
|
|
296
|
+
* closing transition itself is the save where the status is still OPEN in the database, so it
|
|
297
|
+
* passes the lock and reaches here with `_lockedAtSave === false`. Anything that gets here WITH
|
|
298
|
+
* the flag set is a permitted edit to a frozen deal, and re-deriving its probability or re-
|
|
299
|
+
* stamping its order from the closing stage would be exactly the corruption the lock exists to
|
|
300
|
+
* stop.
|
|
301
|
+
*/
|
|
302
|
+
/**
|
|
303
|
+
* AND NEITHER RUNS ON A DECLARED TRANSITION. See `DeclareTransition`: a close's probability and a
|
|
304
|
+
* reopen's are the caller's considered figures, already stamped onto the event, and re-deriving
|
|
305
|
+
* them from the arriving stage is how a reopen came to disagree with its own provenance row.
|
|
306
|
+
*/
|
|
307
|
+
const stageDefaults = this._lockedAtSave || this._declaredTransition ? null : await this.planStageDefaults();
|
|
308
|
+
// Nothing to number: an existing deal, or one that already has a number. A number is only ever
|
|
309
|
+
// assigned once — it appears in contracts, orders and people's email, so re-saving a deal must
|
|
310
|
+
// never renumber it.
|
|
311
|
+
/**
|
|
312
|
+
* ── COMPUTED ONCE, BECAUSE SPLITTING IT ACROSS THE ARMS GOT IT WRONG ──────────────────
|
|
313
|
+
*
|
|
314
|
+
* The arms below route on NUMBERING — "has this deal already got a number" — and the status
|
|
315
|
+
* default is a question about CREATION. Those are not the same question, and treating them as
|
|
316
|
+
* one hard-coded the default OFF for the exact case the early-return guard was written to cover.
|
|
317
|
+
*
|
|
318
|
+
* A create that supplies its own `DealNumber` — an importer does precisely this — is not
|
|
319
|
+
* saved, so it still needs a status; but `this.IsSaved || this.DealNumber` is truthy, so it took
|
|
320
|
+
* the first arm and got `needsStatusDefault: false`. The guard term in `saveWithinScope` says in
|
|
321
|
+
* its own comment that it exists so such a create "would not take the fast path and land NULL
|
|
322
|
+
* again", and the caller then guaranteed it landed NULL anyway. The term and its caller
|
|
323
|
+
* contradicted each other, and the importer was the case that lost.
|
|
324
|
+
*
|
|
325
|
+
* `!this.IsSaved` is the whole test: an update never needs a default (it either has a status or
|
|
326
|
+
* the rep cleared it deliberately — SD37), and every create does unless one was supplied.
|
|
327
|
+
*/
|
|
328
|
+
const needsStatusDefault = !this.IsSaved && !this.DealStatusTypeID;
|
|
329
|
+
const saved = this.IsSaved || this.DealNumber
|
|
330
|
+
? await this.saveWithinScope(options, {
|
|
331
|
+
stageOrder, stageMove, stageDefaults, amountMayHaveMoved, assignNumber: false,
|
|
332
|
+
needsStatusDefault,
|
|
333
|
+
})
|
|
334
|
+
: await this.saveWithinScope(options, {
|
|
335
|
+
stageOrder, stageMove, stageDefaults, amountMayHaveMoved, assignNumber: true,
|
|
336
|
+
needsStatusDefault,
|
|
337
|
+
});
|
|
338
|
+
// The declaration is cleared by the `finally` in `Save()` above, which covers this exit and the
|
|
339
|
+
// three guards that return before ever reaching here. The clear that used to live on this line
|
|
340
|
+
// covered only this one.
|
|
341
|
+
if (!saved) {
|
|
342
|
+
this.explainOrderProvisioningFailure();
|
|
343
|
+
}
|
|
344
|
+
return saved;
|
|
345
|
+
}
|
|
346
|
+
/* ── STAGE PROVENANCE ─────────────────────────────────────────────────────────────────────────
|
|
347
|
+
*
|
|
348
|
+
* Deliberately its own block, and deliberately not folded into `provisionEmbeddedOrder` or the
|
|
349
|
+
* numbering path. These are three unrelated jobs that happen to share a save, and keeping them
|
|
350
|
+
* separate is what makes the next merge here a merge rather than an archaeology exercise.
|
|
351
|
+
*/
|
|
352
|
+
/**
|
|
353
|
+
* The snapshot an ordinary stage move owes the append-only log, or null when the deal did not move.
|
|
354
|
+
*
|
|
355
|
+
* ── THE PERSISTED VALUES, read before the save overwrites them ──
|
|
356
|
+
*
|
|
357
|
+
* `OldValue` is what is on disk, which is the only thing that makes a transition detectable — the
|
|
358
|
+
* same discipline the close lock uses when it asks whether the deal was ALREADY locked rather than
|
|
359
|
+
* whether it is about to be.
|
|
360
|
+
*
|
|
361
|
+
* ── THE STAMPS COME FROM THE PRIOR VALUES, NOT THE NEW ONES ──
|
|
362
|
+
*
|
|
363
|
+
* That is the whole point of the table. A board drag applies the target stage's probability default,
|
|
364
|
+
* so reading the current value here would record the number the deal acquired by ARRIVING rather
|
|
365
|
+
* than the one it held on the way out — and every velocity report built on it would be quietly
|
|
366
|
+
* wrong.
|
|
367
|
+
*
|
|
368
|
+
* A brand-new deal has no prior stage and owes no event: `priorStageID` is null, so it is not a
|
|
369
|
+
* MOVE. It is a birth, and `DealStageEvent` is a log of movement.
|
|
370
|
+
*/
|
|
371
|
+
planStageEvent() {
|
|
372
|
+
/**
|
|
373
|
+
* A DEAL BEING CREATED OWES NO EVENT, and `OldValue === null` is not a reliable way to ask.
|
|
374
|
+
*
|
|
375
|
+
* `NewDeal()` preselects a pipeline and its first stage; the rep then picks the stage they
|
|
376
|
+
* actually want. That is TWO assignments before the first save, so the first becomes `OldValue`
|
|
377
|
+
* and the guard below saw a "transition" from a stage the deal was never in — and wrote it into
|
|
378
|
+
* an append-only log, where it cannot be corrected afterwards.
|
|
379
|
+
*
|
|
380
|
+
* `IsSaved` is the question that holds however many times the field was touched. `DealStageEvent`
|
|
381
|
+
* is a log of MOVEMENT, and nothing has moved until there is a row to move from.
|
|
382
|
+
*/
|
|
383
|
+
if (!this.IsSaved) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
const priorStageID = this.GetFieldByName('PipelineStageID')?.OldValue ?? null;
|
|
387
|
+
/**
|
|
388
|
+
* COMPARED CASE-INSENSITIVELY, like every other id comparison in this class.
|
|
389
|
+
*
|
|
390
|
+
* `planStageOrderStatus` and `planStageDefaults` both lowercase before comparing; this one did
|
|
391
|
+
* not. SQL Server hands back uppercase GUIDs and client code generates lowercase, so a caller
|
|
392
|
+
* that normalised its ids — an importer, an Action, anything round-tripping through JSON — got a
|
|
393
|
+
* SELF-TRANSITION recorded: an append-only row saying the deal moved from a stage to itself.
|
|
394
|
+
* Three writers on one trigger and only two of them agreed on how to compare a key.
|
|
395
|
+
*/
|
|
396
|
+
const stageMoved = priorStageID !== null &&
|
|
397
|
+
priorStageID.toLowerCase() !== String(this.PipelineStageID ?? '').toLowerCase();
|
|
398
|
+
/**
|
|
399
|
+
* A DECLARED TRANSITION IS OWED AN EVENT WHETHER OR NOT THE STAGE MOVED.
|
|
400
|
+
*
|
|
401
|
+
* `Sales.CloseDeal` without a `ClosingStageID` changes the STATUS only, and that is still the most
|
|
402
|
+
* consequential thing that ever happens to a deal. It owes a row. This is the case that made the
|
|
403
|
+
* close operation hand-write its own event, and writing it here instead is what removes the second
|
|
404
|
+
* row for the case where the stage DID move.
|
|
405
|
+
*/
|
|
406
|
+
if (!stageMoved && !this._declaredTransition) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
StageID: priorStageID,
|
|
411
|
+
StatusID: this.GetFieldByName('DealStatusTypeID')?.OldValue ?? null,
|
|
412
|
+
Amount: this.GetFieldByName('Amount')?.OldValue ?? null,
|
|
413
|
+
AmountIsComputed: this.GetFieldByName('AmountIsComputed')?.OldValue ?? null,
|
|
414
|
+
Probability: this.GetFieldByName('Probability')?.OldValue ?? null,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The ONE place a save opens a transaction, and the one place the ordering is decided.
|
|
419
|
+
*
|
|
420
|
+
* Four jobs share this scope, and each has a reason to be where it is:
|
|
421
|
+
*
|
|
422
|
+
* 1. **The deal number**, BEFORE the header is written — `DealNumber` carries a filtered UNIQUE
|
|
423
|
+
* index, and assigning it afterwards would mean a second write to a row already visible. The
|
|
424
|
+
* transaction is what keeps the counter gap-free: `spAssignNextDealNumber` increments a locked
|
|
425
|
+
* row, so a save that then fails must roll the increment back.
|
|
426
|
+
* 2. **The order status**, BEFORE the header is written, because an unsaved order is INSERTED in the
|
|
427
|
+
* target status rather than created and then moved — one row, one state, no history of a status
|
|
428
|
+
* it was never really in.
|
|
429
|
+
* 3. **The stage event**, AFTER, because it is provenance for a move that has actually happened.
|
|
430
|
+
* An event for a save that then failed is worse than no event: it is a claim about history.
|
|
431
|
+
* 4. **The `Deal.Amount` cache**, LAST, because `OrderHeader.TotalGross` is trigger-maintained and
|
|
432
|
+
* only reflects this save's lines once they are written. Its trigger is the ORDER changing rather
|
|
433
|
+
* than the stage, which is why the caller decides separately whether it runs.
|
|
434
|
+
*
|
|
435
|
+
* **BOTH-OR-NEITHER, ACROSS ALL THREE.** A moved deal with no event is a hole in an append-only
|
|
436
|
+
* log. A numbered deal that does not exist consumes a number nobody can account for. And a stage
|
|
437
|
+
* change visible while the order it moved is not would be a worse outcome than either alone.
|
|
438
|
+
*
|
|
439
|
+
* `BeginEntityTransaction` JOINS an ambient transaction rather than opening a second, so a caller
|
|
440
|
+
* already in one — `Sales.CloseDeal`, or an integration check — gets a savepoint and everything
|
|
441
|
+
* still settles together.
|
|
442
|
+
*
|
|
443
|
+
* **A save with nothing to do here takes no transaction at all** (see the caller). The overwhelming
|
|
444
|
+
* majority of deal saves are edits to a field nobody is watching, and wrapping those in a scope
|
|
445
|
+
* would be a cost paid on every keystroke for a guarantee they do not need.
|
|
446
|
+
*/
|
|
447
|
+
async saveWithinScope(options, work) {
|
|
448
|
+
if (!work.assignNumber &&
|
|
449
|
+
!work.stageOrder &&
|
|
450
|
+
!work.stageMove &&
|
|
451
|
+
!work.stageDefaults &&
|
|
452
|
+
!work.amountMayHaveMoved &&
|
|
453
|
+
// Without this term a create that supplied its own DealNumber and touched no stage would take
|
|
454
|
+
// the fast path and land NULL again -- the exact gap this closes, through the one door left open.
|
|
455
|
+
!work.needsStatusDefault) {
|
|
456
|
+
return super.Save(options);
|
|
457
|
+
}
|
|
458
|
+
const provider = this.ProviderToUse;
|
|
459
|
+
if (!provider?.BeginEntityTransaction) {
|
|
460
|
+
throw new Error('DealEntityServer.Save: the provider cannot open a transaction, so a deal number, an order ' +
|
|
461
|
+
'status and a stage event cannot be written atomically. This class is server-only — it must ' +
|
|
462
|
+
'run against a database provider.');
|
|
463
|
+
}
|
|
464
|
+
const scope = await provider.BeginEntityTransaction();
|
|
465
|
+
try {
|
|
466
|
+
if (work.assignNumber) {
|
|
467
|
+
this.DealNumber = await getNextDealNumber(this.ContextCurrentUser, this.ProviderToUse);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* BEFORE the order status and before the save, because these are columns on the DEAL and the
|
|
471
|
+
* save is what persists them. The order-status writer talks to another app's record and the
|
|
472
|
+
* stage event is appended afterwards; this one has to be in place by the time `super.Save`
|
|
473
|
+
* runs or it writes nothing.
|
|
474
|
+
*/
|
|
475
|
+
if (work.stageDefaults) {
|
|
476
|
+
this.applyStageDefaults(work.stageDefaults);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* AFTER the stage defaults, and the order is load-bearing rather than tidy.
|
|
480
|
+
*
|
|
481
|
+
* `applyStageDefaults` decides whether to take the stage's status by asking
|
|
482
|
+
* `callerSuppliedValue`, and on a CREATE that question is only "is there a value". Setting a
|
|
483
|
+
* fallback first would therefore look exactly like a rep's own choice and suppress the
|
|
484
|
+
* stage's legitimate opening status for good. Running last means this fills only what the
|
|
485
|
+
* stage could not: a deal with no stage, or one whose stage declares a status that locks.
|
|
486
|
+
*/
|
|
487
|
+
if (work.needsStatusDefault && !this.DealStatusTypeID) {
|
|
488
|
+
this.DealStatusTypeID = await this.defaultOpeningStatusID();
|
|
489
|
+
}
|
|
490
|
+
if (work.stageOrder) {
|
|
491
|
+
await this.applyStageOrderStatus(work.stageOrder);
|
|
492
|
+
}
|
|
493
|
+
const saved = await super.Save(options);
|
|
494
|
+
if (!saved) {
|
|
495
|
+
await scope.Rollback();
|
|
496
|
+
/**
|
|
497
|
+
* ── THE NUMBER GOES BACK TOO, OR THE DEAL BECOMES UNSAVEABLE ────────────────────────
|
|
498
|
+
*
|
|
499
|
+
* `DealNumber` was drawn from the sequence INSIDE this scope. The rollback returns the
|
|
500
|
+
* counter, but the value stayed on the in-memory record — and `Save()` decides whether to
|
|
501
|
+
* draw one by asking `this.IsSaved || this.DealNumber`. So the retry took the
|
|
502
|
+
* `assignNumber: false` branch and re-inserted a number the sequence had already
|
|
503
|
+
* re-issued to somebody else.
|
|
504
|
+
*
|
|
505
|
+
* The deal was then unsaveable for the life of the tab, and the error named a unique index
|
|
506
|
+
* on DealNumber rather than whatever had actually failed the first time — so the rep saw a
|
|
507
|
+
* duplicate-key message about a field they had never touched.
|
|
508
|
+
*
|
|
509
|
+
* Cleared only when THIS scope drew it: a retry of an already-numbered deal must keep its
|
|
510
|
+
* number, because that number is in contracts, orders and people's email.
|
|
511
|
+
*/
|
|
512
|
+
if (work.assignNumber) {
|
|
513
|
+
this.DealNumber = null;
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
if (work.stageMove) {
|
|
518
|
+
await this.appendStageEvent(work.stageMove);
|
|
519
|
+
}
|
|
520
|
+
// LAST, because it reads a total the trigger only produced a moment ago. See the method.
|
|
521
|
+
if (work.amountMayHaveMoved || work.assignNumber) {
|
|
522
|
+
await this.refreshAmountFromOrder();
|
|
523
|
+
}
|
|
524
|
+
await scope.Commit();
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
LogError(`DealEntityServer.saveWithinScope failed for deal ${this.ID}: ${err}`);
|
|
529
|
+
try {
|
|
530
|
+
await scope.Rollback();
|
|
531
|
+
}
|
|
532
|
+
catch (rollbackErr) {
|
|
533
|
+
LogError(`Failed to roll back after a failed deal save: ${rollbackErr}`);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* ── THE SAME COMPENSATION AS THE `!saved` BRANCH, AND FOR THE SAME REASON ──────────────
|
|
537
|
+
*
|
|
538
|
+
* The branch above documents this exactly and then only half of the exits performed it. A
|
|
539
|
+
* `return false` from the save cleared the number; a THROW did not, and the rollback still
|
|
540
|
+
* returned the counter — so the value stayed on the in-memory record, `Save()` asked
|
|
541
|
+
* `this.IsSaved || this.DealNumber`, found it truthy, took the `assignNumber: false` arm and
|
|
542
|
+
* re-inserted a number the sequence had already re-issued. Duplicate key, deal unsaveable for
|
|
543
|
+
* the life of the tab, and an error naming a field the rep never touched. Precisely the
|
|
544
|
+
* failure the comment above says was fixed.
|
|
545
|
+
*
|
|
546
|
+
* THE THROWING PATHS ARE REAL, not hypothetical. `getNextDealNumber()` runs inside this
|
|
547
|
+
* scope, and at least three later statements throw rather than return false:
|
|
548
|
+
* `defaultOpeningStatusID()`'s RunView, `applyStageOrderStatus()` whose
|
|
549
|
+
* `BeginEntityTransaction` sits outside its own try/catch, and the post-save
|
|
550
|
+
* `appendStageEvent()`. Any of them lands here.
|
|
551
|
+
*
|
|
552
|
+
* Guarded by `work.assignNumber` for the reason the other branch gives: a retry of an
|
|
553
|
+
* ALREADY-numbered deal must keep its number, because that number is in contracts, orders
|
|
554
|
+
* and people's email. Only the scope that drew one may give it back.
|
|
555
|
+
*/
|
|
556
|
+
if (work.assignNumber) {
|
|
557
|
+
this.DealNumber = null;
|
|
558
|
+
}
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Writes the one `DealStageEvent` a move owes.
|
|
564
|
+
*
|
|
565
|
+
* APPEND-ONLY — never edited, never deleted. Note what is NOT here: no close, no routing, no
|
|
566
|
+
* downstream call. A stage change is a stage change; closing is `Sales.CloseDeal` and stays an
|
|
567
|
+
* explicit act, even when the stage a deal moves into is the one a pipeline calls Signed.
|
|
568
|
+
*/
|
|
569
|
+
async appendStageEvent(prior) {
|
|
570
|
+
const provider = this.ProviderToUse;
|
|
571
|
+
const user = this.ContextCurrentUser;
|
|
572
|
+
const event = await provider.GetEntityObject('MJ_BizApps_Sales: Deal Stage Events', user);
|
|
573
|
+
event.NewRecord();
|
|
574
|
+
event.DealID = this.ID;
|
|
575
|
+
event.FromStageID = prior.StageID;
|
|
576
|
+
event.ToStageID = this.PipelineStageID;
|
|
577
|
+
event.FromDealStatusTypeID = prior.StatusID;
|
|
578
|
+
event.ToDealStatusTypeID = this.DealStatusTypeID;
|
|
579
|
+
event.ChangedByUserID = user?.ID ?? null;
|
|
580
|
+
event.ChangedAt = new Date();
|
|
581
|
+
event.AmountAtTransition = prior.Amount;
|
|
582
|
+
/**
|
|
583
|
+
* ── AND WHOSE NUMBER IT WAS, STAMPED BESIDE IT ──────────────────────────────────────────────
|
|
584
|
+
*
|
|
585
|
+
* Finance reads close amounts out of this table and could not classify them. The amount was
|
|
586
|
+
* stamped; its provenance was not — and the flag that carries it, `Deal.AmountIsComputed`, is on a
|
|
587
|
+
* row that keeps changing. So a deal repriced after its close made its own close amount
|
|
588
|
+
* unclassifiable, and there was no way to tell an engine-priced booking from a typed one after the
|
|
589
|
+
* fact.
|
|
590
|
+
*
|
|
591
|
+
* `Set` rather than a typed property: the column is registered as an `EntityField` by an additive
|
|
592
|
+
* migration, and the generated entity subclass does not carry it until CodeGen next runs — which
|
|
593
|
+
* cannot be run against this database (see that migration's header for why). The field exists in
|
|
594
|
+
* metadata, so this writes through the same path every other field does.
|
|
595
|
+
*
|
|
596
|
+
* NOT BACKFILLABLE, which is the whole argument for adding it now: every row already written has
|
|
597
|
+
* NULL here and always will, because the answer for those transitions is genuinely unknown. Its
|
|
598
|
+
* value starts today.
|
|
599
|
+
*/
|
|
600
|
+
event.Set('AmountAtTransitionIsComputed', prior.AmountIsComputed);
|
|
601
|
+
event.ProbabilityAtTransition = prior.Probability;
|
|
602
|
+
// A declared transition's reason — the close's routing note, the reopen's justification. An
|
|
603
|
+
// ordinary stage move carries none, which is why this is null far more often than not.
|
|
604
|
+
event.Notes = this._declaredTransition?.Note ?? null;
|
|
605
|
+
if (!(await event.Save())) {
|
|
606
|
+
// THROWN, not returned: the caller's scope must roll the deal move back with it.
|
|
607
|
+
throw new Error(`the stage event could not be written: ${event.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
608
|
+
}
|
|
609
|
+
// Read back by `Sales.CloseDeal` / `Sales.ReopenDeal` for their `DealStageEventID` result. They
|
|
610
|
+
// used to have it because they wrote the row; this is what replaces that.
|
|
611
|
+
this._lastStageEventID = event.ID;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Gives a brand-new deal its embedded order, in Draft, on the deal's FIRST save.
|
|
615
|
+
*
|
|
616
|
+
* ── WHY THE ENTITY SERVER AND NOT THE WORKSPACE ──
|
|
617
|
+
*
|
|
618
|
+
* The workspace called `OrderID_EnsureObject()` from `AddLine()`, so a deal only got an order when
|
|
619
|
+
* somebody added a line THROUGH THE UI. An agent, an importer, a remote operation or a plain
|
|
620
|
+
* `BaseEntity.Save()` each produced a deal with no order at all -- and S-US4 puts the order at
|
|
621
|
+
* creation, not at first line. Same reasoning as the close lock below: a rule the UI enforces holds
|
|
622
|
+
* only until something that is not the UI writes.
|
|
623
|
+
*
|
|
624
|
+
* ── WHY FIRST SAVE AND NOT NewRecord() ──
|
|
625
|
+
*
|
|
626
|
+
* `OrderHeader.CompanyID` is NOT NULL, and a deal does not know its company until
|
|
627
|
+
* `stampCompanyFromPipeline()` has run: the caller supplies a PipelineID and the selling company is
|
|
628
|
+
* derived from it (D-4). Provisioning in `NewRecord()` would mean inventing a company or building an
|
|
629
|
+
* order that cannot be inserted. So it happens here, after the stamps, on the one call where
|
|
630
|
+
* `IsSaved` is still false.
|
|
631
|
+
*
|
|
632
|
+
* ── WHY THE GUARD IS `IsSaved`, AND NOT `IsSaved || OrderID` ──
|
|
633
|
+
*
|
|
634
|
+
* It was the second form, and that was a real bug rather than a stylistic choice. `Ensure()` assigns
|
|
635
|
+
* the new peer's key to `OrderID` immediately, so a caller that reached the order BEFORE saving --
|
|
636
|
+
* the workspace's `AddLine()`, an importer building a deal and its lines for one `Save()`, or any of
|
|
637
|
+
* the line checks in `save-deal` -- arrived here with `OrderID` already populated. The guard then
|
|
638
|
+
* returned early, the stamps below never ran, and the save died inside orders on
|
|
639
|
+
* `CompanyID cannot be null`: a NOT NULL complaint about a column two apps away from the mistake.
|
|
640
|
+
*
|
|
641
|
+
* Idempotent twice over all the same: `Ensure()` returns any existing peer, `IsSaved` means a re-save
|
|
642
|
+
* never re-provisions, and an order that is ALREADY PERSISTED is left untouched (below) so attaching
|
|
643
|
+
* an existing order stays a legitimate thing to do.
|
|
644
|
+
*
|
|
645
|
+
* ── WHAT SALES STATES, AND WHAT IT DOES NOT ──
|
|
646
|
+
*
|
|
647
|
+
* Company, type, and who to bill. NOT a status: orders' own `NewRecord()` defaults it to Draft, and
|
|
648
|
+
* restating that here would be sales asserting an order lifecycle it does not own. NOT an
|
|
649
|
+
* `OrderNumber`: the server subclass in orders mints it, which is what the diagnostic below is about.
|
|
650
|
+
*
|
|
651
|
+
* `BillToOrganizationID` and `BillToPersonID` are BOTH set, and unlike the contract case that is
|
|
652
|
+
* legal -- `OrderHeader` has no XOR across them, checked against `sys.check_constraints`. Do not
|
|
653
|
+
* carry the contract's exactly-one rule across by analogy; it is that table's rule, not a house style.
|
|
654
|
+
* `Deal.AccountID` is a `SalesAccount`, an IsA child of common's `Organization`, so its key IS an
|
|
655
|
+
* organization key; `PrimaryContactID` is a `SalesContact`, an IsA child of `Person`.
|
|
656
|
+
*/
|
|
657
|
+
provisionEmbeddedOrder() {
|
|
658
|
+
/**
|
|
659
|
+
* ── THE TEST IS "HAS A REAL ORDER", NOT "IS A NEW DEAL" ──
|
|
660
|
+
*
|
|
661
|
+
* This used to return early on `IsSaved`, which meant provisioning could only ever happen on a
|
|
662
|
+
* deal's FIRST save. Every deal that already existed without an order was therefore unable to
|
|
663
|
+
* acquire one — for good: every SQL-seeded demo deal, and every deal created before S-US4
|
|
664
|
+
* landed. Reaching for `OrderID_EnsureObject()` on one of those built an UNSTAMPED order and the
|
|
665
|
+
* save died inside orders on `CompanyID cannot be null`, two apps away from the cause. Found by
|
|
666
|
+
* `scripts/seed-demo-lines.mjs` failing on four of five deals.
|
|
667
|
+
*
|
|
668
|
+
* Asking about the ORDER instead covers both: a deal with a persisted order is left alone (the
|
|
669
|
+
* S-US5 rule — finance may already have worked on it), and a deal without one gets one whenever
|
|
670
|
+
* it is next saved, however old it is. The check is on `IsSaved` OF THE ORDER and not on
|
|
671
|
+
* `this.OrderID`, because `Ensure()` assigns the new peer's key immediately — which is the DN-7
|
|
672
|
+
* trap, and testing the FK here would walk straight back into it.
|
|
673
|
+
*/
|
|
674
|
+
if (this.OrderID_Object?.IsSaved) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (!this.CompanyID) {
|
|
678
|
+
// The pipeline stamp resolved no company, so an order cannot be inserted. Say nothing and
|
|
679
|
+
// let the deal's own validation report the missing pipeline -- failing later inside orders
|
|
680
|
+
// would name a column instead of the actual mistake.
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const order = this.OrderID_EnsureObject();
|
|
684
|
+
this._orderJustProvisioned = true;
|
|
685
|
+
order.CompanyID = this.CompanyID;
|
|
686
|
+
order.OrderType = 'Sale';
|
|
687
|
+
order.BillToOrganizationID = this.AccountID ?? null;
|
|
688
|
+
order.BillToPersonID = this.PrimaryContactID ?? null;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Turns the one failure this provisioning can cause into a message that names its cause.
|
|
692
|
+
*
|
|
693
|
+
* `OrderNumber` is NOT NULL and is minted by the SERVER subclass in orders. If that class is not
|
|
694
|
+
* registered in the process, `ClassFactory` resolves the generated `OrderHeader` instead, nothing
|
|
695
|
+
* mints a number, and the insert dies on a NOT NULL violation raised inside the save graph. What
|
|
696
|
+
* reaches the caller is `Save failed for OrderID_Object (MJ_BizApps_Orders: Order Headers): Error
|
|
697
|
+
* executing SQL` -- which names the entity, not the reason, and reads like a database fault rather
|
|
698
|
+
* than a missing import.
|
|
699
|
+
*
|
|
700
|
+
* SALES MUST NOT FIX THAT BY DEPENDING ON ORDERS' SERVER PACKAGE. Neither `sales-server` nor
|
|
701
|
+
* `sales-core-entities-server` does, and neither should start: loading another app's server classes
|
|
702
|
+
* from inside this one is how two registrations for one key end up decided by import order. MJAPI
|
|
703
|
+
* owns that -- MJ declares orders' server packages itself, which is why the running app works. A
|
|
704
|
+
* bare node harness has to call `LoadBizAppsOrdersServer()` on its own.
|
|
705
|
+
*
|
|
706
|
+
* So this cannot repair anything. It exists to stop the next person debugging the wrong layer.
|
|
707
|
+
*/
|
|
708
|
+
explainOrderProvisioningFailure() {
|
|
709
|
+
const order = this.OrderID_Object;
|
|
710
|
+
if (!order || order.IsSaved || order.OrderNumber) {
|
|
711
|
+
return; // whatever failed, it was not this
|
|
712
|
+
}
|
|
713
|
+
LogError('DealEntityServer.Save: the embedded order was not written and has no OrderNumber. That ' +
|
|
714
|
+
'column is NOT NULL and is minted by the server subclass in orders, so this is what it looks ' +
|
|
715
|
+
'like when OrderEntityServer is not registered in this process and ClassFactory fell back to ' +
|
|
716
|
+
'the generated OrderHeader. In MJAPI it arrives through the dependencies MJ declares; in a ' +
|
|
717
|
+
'script, call LoadBizAppsOrdersServer() before saving. Not a database or constraint problem.');
|
|
718
|
+
}
|
|
719
|
+
get OrderStatusWarnings() {
|
|
720
|
+
return this._orderStatusWarnings;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Resolves what this save's stage change asks of the order. Reads only; opens nothing.
|
|
724
|
+
*
|
|
725
|
+
* ── WHAT COUNTS AS "CHANGING STAGE" ──
|
|
726
|
+
*
|
|
727
|
+
* `OldValue` versus the current value, the same mechanism {@link checkCloseLock} keys on. A save
|
|
728
|
+
* that does not touch `PipelineStageID` plans nothing, so an ordinary edit — a rename, a nudge to
|
|
729
|
+
* `NextStep` — never reaches the order. That matters more than it looks: an order that got
|
|
730
|
+
* re-Quoted on every save of its deal would be a write amplification nobody asked for, and on a
|
|
731
|
+
* Confirmed order it would be a refusal warning on every single save.
|
|
732
|
+
*
|
|
733
|
+
* A BRAND-NEW deal also counts as entering its first stage. `CanTransition` treats a null `from` as
|
|
734
|
+
* "may be created in any legal state", so a new deal on a stage naming `Quoted` gets an order
|
|
735
|
+
* created Quoted rather than created Draft and then moved — one row, one state, no history of a
|
|
736
|
+
* status it was never really in.
|
|
737
|
+
*/
|
|
738
|
+
/**
|
|
739
|
+
* What the stage says this deal's probability and forecast category become — read BEFORE the
|
|
740
|
+
* transaction, like every other plan in this class.
|
|
741
|
+
*
|
|
742
|
+
* ── WHY THIS IS ON THE WRITE PATH AND NOT IN THE WORKSPACE ──────────────────────────────────
|
|
743
|
+
*
|
|
744
|
+
* It was in the workspace: `DealWorkspaceComponent.ApplyStageDefaults`, called from the stage picker.
|
|
745
|
+
* So a stage set by an agent, by the S6 HubSpot importer, by an Action or by any API caller got
|
|
746
|
+
* NEITHER value — the deal landed with whatever probability the caller happened to supply, or null,
|
|
747
|
+
* while the pipeline designer's answer sat unused in the stage row. Exactly the shape of the order
|
|
748
|
+
* provisioning that used to live in the workspace and now lives in `Save()`: a rule the UI enforces
|
|
749
|
+
* is a rule only the UI obeys.
|
|
750
|
+
*
|
|
751
|
+
* ── SAME TRIGGER AS THE OTHER THREE, DELIBERATELY A SEPARATE READ ───────────────────────────
|
|
752
|
+
*
|
|
753
|
+
* Keyed on `PipelineStageID` changing, like the order-status writer and the stage event. It reads the
|
|
754
|
+
* stage row a second time rather than widening `planStageOrderStatus`, and that is a considered
|
|
755
|
+
* trade: one extra read on a stage change — a rare event — against touching a method whose exact
|
|
756
|
+
* behaviour is pinned by `close-won-order.CO3`, `CO5` and three mutants. Cheap insurance beats a
|
|
757
|
+
* clever saving here.
|
|
758
|
+
*
|
|
759
|
+
* ── IT DOES NOT FIRE ON PROVISIONING, unlike the order-status writer ────────────────────────
|
|
760
|
+
*
|
|
761
|
+
* `planStageOrderStatus` also runs when an existing deal acquires an order (`_orderJustProvisioned`,
|
|
762
|
+
* SD25), because an order that has just come into existence has never been told anything. These
|
|
763
|
+
* fields are different: the deal has held them all along, and re-applying a stage default to a deal
|
|
764
|
+
* whose stage did not move would overwrite a number a rep tuned by hand.
|
|
765
|
+
*/
|
|
766
|
+
async planStageDefaults() {
|
|
767
|
+
const stageID = this.PipelineStageID;
|
|
768
|
+
if (!stageID) {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
if (this.IsSaved) {
|
|
772
|
+
const previous = this.GetFieldByName('PipelineStageID')?.OldValue;
|
|
773
|
+
if (previous && String(previous).toLowerCase() === String(stageID).toLowerCase()) {
|
|
774
|
+
return null; // the stage did not move; these fields are not this save's business
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const view = this.ProviderToUse;
|
|
778
|
+
const result = await view.RunView({
|
|
779
|
+
EntityName: STAGE_ENTITY,
|
|
780
|
+
ExtraFilter: `ID = '${stageID}'`,
|
|
781
|
+
ResultType: 'simple',
|
|
782
|
+
Fields: ['Probability', 'ForecastCategoryTypeID', 'DealStatusTypeID'],
|
|
783
|
+
}, this.ContextCurrentUser);
|
|
784
|
+
if (!result.Success) {
|
|
785
|
+
// Not fatal, for the same reason the order-status read is not: a stage lookup that fails must
|
|
786
|
+
// not stop a rep moving a card. Recorded as a warning like anything else that did not happen.
|
|
787
|
+
this._orderStatusWarnings.push(`Stage defaults were not applied: the stage could not be read ` +
|
|
788
|
+
`(${result.ErrorMessage ?? 'unknown error'}).`);
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
const row = (result.Results ?? [])[0];
|
|
792
|
+
if (!row) {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* ── A STAGE MAY NAME THE STATUS A DEAL SITS IN. IT MAY NOT CLOSE ONE ────────────────────────
|
|
797
|
+
*
|
|
798
|
+
* Deriving the status from the stage without this gate broke the rule this app exists to uphold.
|
|
799
|
+
* The seeded pipelines declare a LOCKING status on their winning and losing stages — "Signed" ->
|
|
800
|
+
* Won, "Lost" -> Lost — so an ordinary save that moved a deal into one of those stages CLOSED IT
|
|
801
|
+
* BY SIDE EFFECT: locked, `IsWon` set, no `DealStageEvent` naming a close, no routing evaluated,
|
|
802
|
+
* no contract, no tasks, and `Sales.CloseDeal` never invoked. A board drag would have booked
|
|
803
|
+
* revenue.
|
|
804
|
+
*
|
|
805
|
+
* That is precisely what the master plan forbids: stages carry no `IsWon`/`IsClosed` of their own
|
|
806
|
+
* and point at a status that does, so that "Closed Won" is a LABEL — and closing stays an explicit
|
|
807
|
+
* act even when the stage a deal enters is the one a pipeline calls Signed.
|
|
808
|
+
*
|
|
809
|
+
* Found by `71-lost-and-reopen`, which moves a deal into the losing stage with a plain save before
|
|
810
|
+
* closing it through the panel: the deal arrived at the close already locked, so the close could
|
|
811
|
+
* not stamp its loss reason. A green integration suite did not see it — no check moved a deal into
|
|
812
|
+
* a closing stage, because until now nothing about a stage could close one. `SD35` does now.
|
|
813
|
+
*
|
|
814
|
+
* The gate reads `LocksDeal` off the status row, never a name (§3). A stage whose status locks
|
|
815
|
+
* contributes nothing here; the deal keeps whatever status it had and only `Sales.CloseDeal` can
|
|
816
|
+
* change that.
|
|
817
|
+
*/
|
|
818
|
+
const stageStatusID = row.DealStatusTypeID ?? null;
|
|
819
|
+
let derivableStatusID = null;
|
|
820
|
+
if (stageStatusID) {
|
|
821
|
+
const status = await view.RunView({
|
|
822
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
823
|
+
ExtraFilter: `ID = '${stageStatusID}'`,
|
|
824
|
+
ResultType: 'simple',
|
|
825
|
+
Fields: ['LocksDeal'],
|
|
826
|
+
}, this.ContextCurrentUser);
|
|
827
|
+
const flags = (status.Results ?? [])[0];
|
|
828
|
+
// Unreadable is treated as "do not derive". Failing closed here costs a rep one dropdown; the
|
|
829
|
+
// other direction closes deals nobody asked to close.
|
|
830
|
+
if (status.Success && flags && flags.LocksDeal !== true) {
|
|
831
|
+
derivableStatusID = stageStatusID;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
Probability: row.Probability ?? null,
|
|
836
|
+
ForecastCategoryTypeID: row.ForecastCategoryTypeID ?? null,
|
|
837
|
+
DealStatusTypeID: derivableStatusID,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Fills the forecast fields from the stage, WITHOUT overwriting a value this save set deliberately.
|
|
842
|
+
*
|
|
843
|
+
* ── THE RULE IS THE AMOUNT CACHE'S RULE ─────────────────────────────────────────────────────
|
|
844
|
+
*
|
|
845
|
+
* `Deal.Amount` distinguishes a computed figure from a stated one and never overwrites the stated
|
|
846
|
+
* one (SD22). The same distinction applies here and is read the same way: a field the caller TOUCHED
|
|
847
|
+
* in this save is theirs, and a field they did not touch is the stage's to fill.
|
|
848
|
+
*
|
|
849
|
+
* `Dirty` is what expresses that. It is true only when this save changed the value, so:
|
|
850
|
+
*
|
|
851
|
+
* · A rep dragging a card sets no probability -> not dirty -> the stage's number lands.
|
|
852
|
+
* · A rep who moves the stage AND types 85 -> dirty -> 85 stands, which is the point of the
|
|
853
|
+
* workspace comment promising both stay editable.
|
|
854
|
+
* · THERE IS NO UI COPY ANY MORE. The claim that stood here — that the workspace's own
|
|
855
|
+
* `ApplyStageDefaults` set both fields before saving, so they arrived dirty and were respected,
|
|
856
|
+
* so "the UI copy and this one cannot fight" — was WRONG, in the damaging direction. Arriving
|
|
857
|
+
* dirty is precisely what made this method respect a number the UI had ALREADY overwritten.
|
|
858
|
+
* Two copies did it, the workspace's and the board's, and BD6 stayed green because it drives the
|
|
859
|
+
* entity layer and never touches either. Both are deleted; this is the only writer.
|
|
860
|
+
* · An importer that supplies a historical probability keeps it; one that supplies none inherits
|
|
861
|
+
* the pipeline's design instead of landing on null.
|
|
862
|
+
*/
|
|
863
|
+
/**
|
|
864
|
+
* Did the CALLER put this value here in this save, or is it mine to fill?
|
|
865
|
+
*
|
|
866
|
+
* ── TWO OF MJ'S SEMANTICS BITE ON CREATE, AND THE ANSWER DIFFERS BECAUSE OF THEM ─────────────
|
|
867
|
+
*
|
|
868
|
+
* On an UPDATE, `Dirty` is exactly the question: it is true when this save changed the value.
|
|
869
|
+
*
|
|
870
|
+
* On a CREATE it is useless. A first assignment does not mark a field dirty, and the first write
|
|
871
|
+
* BECOMES the `OldValue` — so `Dirty` is false for a value the caller definitely supplied, and
|
|
872
|
+
* `OldValue` is a value that was never on disk. Asking `Dirty` on a new record therefore says "not
|
|
873
|
+
* theirs" about everything the caller just set.
|
|
874
|
+
*
|
|
875
|
+
* `applyStageDefaults` already knew this and guarded with `creating = !this.IsSaved`. Two other
|
|
876
|
+
* places did not, and both were wrong in the same direction:
|
|
877
|
+
*
|
|
878
|
+
* · `planStageEvent` inferred a birth from `OldValue === null`. But a new deal whose stage is set
|
|
879
|
+
* TWICE before the first save — `NewDeal()` preselects a pipeline and its first stage, the rep
|
|
880
|
+
* then picks a different one — has a non-null `OldValue` holding a stage the deal was NEVER IN.
|
|
881
|
+
* It appended a transition event for it, into an append-only log. `BD1` asserts against exactly
|
|
882
|
+
* that and passes only because it assigns the stage once.
|
|
883
|
+
* · `ownerStampEditRefusal` asked `Dirty`, so an importer doing `NewRecord()` ->
|
|
884
|
+
* `OwnerEmployeeID = X` -> `Save()` slipped straight through the refusal and created a deal whose
|
|
885
|
+
* owner column and owner-role roster name different people — the state SD26 exists to forbid.
|
|
886
|
+
*
|
|
887
|
+
* So the question lives here now, once, and the callers ask it rather than each re-deriving it.
|
|
888
|
+
*/
|
|
889
|
+
callerSuppliedValue(fieldName, current) {
|
|
890
|
+
if (!this.IsSaved) {
|
|
891
|
+
return current !== null && current !== undefined && current !== '';
|
|
892
|
+
}
|
|
893
|
+
return !!this.GetFieldByName(fieldName)?.Dirty;
|
|
894
|
+
}
|
|
895
|
+
applyStageDefaults(plan) {
|
|
896
|
+
/**
|
|
897
|
+
* ── TWO DIFFERENT TESTS, BECAUSE `Dirty` DOES NOT MEAN THE SAME THING ON A NEW RECORD ───────
|
|
898
|
+
*
|
|
899
|
+
* The first version used `Dirty` for both cases and `board-move.BD2` caught it immediately: a
|
|
900
|
+
* deal CREATED with probability 30 had it silently replaced by its stage's 10, because on a new
|
|
901
|
+
* record a field the caller assigned is not reported dirty. The stage event then stamped 10 as
|
|
902
|
+
* the departure value and BD2 said so — expected 30, got 10. Exactly the check doing its job.
|
|
903
|
+
*
|
|
904
|
+
* So the question is asked the way it actually differs:
|
|
905
|
+
*
|
|
906
|
+
* CREATING — respect any value the caller supplied, fill only what they left empty. `Dirty` is
|
|
907
|
+
* not usable here, and "is it null" is precisely the intent anyway.
|
|
908
|
+
* MOVING — respect a value set in THIS save, otherwise take the stage's. This is the case the
|
|
909
|
+
* feature exists for: dragging a card should move the probability with it, and
|
|
910
|
+
* `Dirty` is exactly "the caller touched it this time".
|
|
911
|
+
*/
|
|
912
|
+
// The create-versus-update distinction now lives in `callerSuppliedValue`. It was written out
|
|
913
|
+
// twice here first, which is how two other places came to get it wrong independently.
|
|
914
|
+
if (!this.callerSuppliedValue('Probability', this.Probability)) {
|
|
915
|
+
this.Probability = plan.Probability;
|
|
916
|
+
}
|
|
917
|
+
if (!this.callerSuppliedValue('ForecastCategoryTypeID', this.ForecastCategoryTypeID)) {
|
|
918
|
+
this.ForecastCategoryTypeID = plan.ForecastCategoryTypeID;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* ── THE STATUS COMES FROM THE STAGE, AND A DEAL WITH NO STATUS IS INVISIBLE ──────────────────
|
|
922
|
+
*
|
|
923
|
+
* `DealWorkspaceService.NewDeal()` is `NewRecord()` and nothing else, and `DealStatusTypeID` is
|
|
924
|
+
* nullable with no column default. So a rep who never touched the Status select saved a deal that
|
|
925
|
+
* every `IsOpen`/`IsWon` rollup skipped — and while the roster and summary queries still INNER
|
|
926
|
+
* JOINed that FK, such a deal was invisible to every surface in the app. It was also what made
|
|
927
|
+
* `71-lost-and-reopen` fail on a JOIN rather than on its subject.
|
|
928
|
+
*
|
|
929
|
+
* The answer was already three-quarters built. `PipelineStage.DealStatusTypeID` is seeded on every
|
|
930
|
+
* stage, `StageRow` loads it, and `deal-board.component.ts` derives `IsClosing` from it — reading
|
|
931
|
+
* it as the source of truth. All that was missing was the write path agreeing.
|
|
932
|
+
*
|
|
933
|
+
* ── WHAT THE `callerSuppliedValue` GUARD BUYS, AND ONE THING IT DOES NOT ─────────────────────
|
|
934
|
+
*
|
|
935
|
+
* A status the caller supplied IN THIS SAVE wins: it arrives dirty (or, on a create, simply
|
|
936
|
+
* present), so a rep who picked one keeps it. That is the same rule probability and forecast
|
|
937
|
+
* category follow, and it is why this belongs here rather than in a fourth writer.
|
|
938
|
+
*
|
|
939
|
+
* What it does NOT do is protect a status set in an EARLIER save from a later stage move. `Dirty`
|
|
940
|
+
* is per-save, so dragging a card re-derives the status from the arriving stage exactly as it
|
|
941
|
+
* re-derives the probability. For probability that is the designed behaviour; for status it is
|
|
942
|
+
* worth knowing, and it is reported rather than special-cased — every stage is seeded with an OPEN
|
|
943
|
+
* status today, so the observable effect is nil until somebody seeds two different open statuses
|
|
944
|
+
* across one pipeline's stages.
|
|
945
|
+
*
|
|
946
|
+
* ── AND THE CLOSE IS NOT AFFECTED, STRUCTURALLY RATHER THAN BY LUCK ──────────────────────────
|
|
947
|
+
*
|
|
948
|
+
* `Sales.CloseDeal` sets the closing status and may move the stage with it. It does not race this
|
|
949
|
+
* writer: a DECLARED TRANSITION suppresses stage defaults entirely (see `DeclareTransition`), so
|
|
950
|
+
* `planStageDefaults()` is never even called on a close or reopen. The closing status wins because
|
|
951
|
+
* this code does not run, not because dirty-tracking happened to favour it.
|
|
952
|
+
*/
|
|
953
|
+
if (!this.callerSuppliedValue('DealStatusTypeID', this.DealStatusTypeID)) {
|
|
954
|
+
// Only when the stage actually declares one. A stage with no status must not blank a deal's.
|
|
955
|
+
if (plan.DealStatusTypeID) {
|
|
956
|
+
this.DealStatusTypeID = plan.DealStatusTypeID;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The status a BRAND-NEW deal starts in when nothing else supplied one.
|
|
962
|
+
*
|
|
963
|
+
* ── WHY A DEAL WITH NO STATUS IS WORSE THAN A DEAL WITH A WRONG ONE ──────────────────────────────
|
|
964
|
+
*
|
|
965
|
+
* `Deal.DealStatusTypeID` is nullable with no column default, and until now nothing in the write path
|
|
966
|
+
* required it. A deal saved without touching Status landed NULL, and every measure that reads the
|
|
967
|
+
* status through `JOIN DealStatusType` silently did not count it — a tile that under-reports rather
|
|
968
|
+
* than one that errors. It also cost a misdiagnosis: `71-lost-and-reopen` failed with
|
|
969
|
+
* `Cannot read properties of undefined (reading 'IsLost')`, which reads as a bad field and was the
|
|
970
|
+
* JOIN dropping the row.
|
|
971
|
+
*
|
|
972
|
+
* ── NEVER DERIVED FROM THE STAGE, AND THE NUMBERS SAY WHY ────────────────────────────────────
|
|
973
|
+
*
|
|
974
|
+
* The obvious-looking fix — take the stage's status — closes deals by side effect. Measured on this
|
|
975
|
+
* host: `Booked` → Won, `Signed` → Won and `Lost` → Lost all carry `LocksDeal = 1`. A deal created
|
|
976
|
+
* in one of those stages would be born closed and locked, and a board drag would book revenue. That
|
|
977
|
+
* is why {@link planStageDefaults} gates its own stage-derived status on `LocksDeal`, and why this
|
|
978
|
+
* fallback asks the VOCABULARY what an opening status is instead of asking the stage.
|
|
979
|
+
*
|
|
980
|
+
* ── THE THIRD CONDITION IS NOT REDUNDANT ────────────────────────────────────────────────────
|
|
981
|
+
*
|
|
982
|
+
* `IsActive = 1 AND IsOpen = 1 AND LocksDeal = 0`. Today every `IsOpen = 1` row also has
|
|
983
|
+
* `LocksDeal = 0`, so the third term selects nothing extra — but that is a property of the seeded
|
|
984
|
+
* data, not an invariant the schema enforces. Nothing stops a tenant declaring an open status that
|
|
985
|
+
* also locks, and since the entire hazard here is closing a deal by side effect, the guard says so
|
|
986
|
+
* rather than relying on the coincidence holding.
|
|
987
|
+
*
|
|
988
|
+
* `DisplayRank` makes the choice deterministic when a tenant defines several opening statuses.
|
|
989
|
+
*
|
|
990
|
+
* ── AND IT LEAVES NULL RATHER THAN REFUSING ──────────────────────────────────────────────────
|
|
991
|
+
*
|
|
992
|
+
* If no active, open, non-locking status exists the vocabulary is misconfigured — but refusing the
|
|
993
|
+
* save would block ALL deal creation on that tenant, which is a worse failure than the gap being
|
|
994
|
+
* closed. Returning null preserves exactly today's behaviour for that case and nothing more.
|
|
995
|
+
*/
|
|
996
|
+
async defaultOpeningStatusID() {
|
|
997
|
+
const view = this.ProviderToUse;
|
|
998
|
+
const result = await view.RunView({
|
|
999
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
1000
|
+
ExtraFilter: 'IsActive = 1 AND IsOpen = 1 AND LocksDeal = 0',
|
|
1001
|
+
OrderBy: 'DisplayRank ASC',
|
|
1002
|
+
ResultType: 'simple',
|
|
1003
|
+
Fields: ['ID'],
|
|
1004
|
+
}, this.ContextCurrentUser);
|
|
1005
|
+
if (!result.Success) {
|
|
1006
|
+
// Unreadable vocabulary is treated as "no default", for the same reason the stage gate fails
|
|
1007
|
+
// closed: a missing status costs a rollup one row, a guessed one corrupts the pipeline.
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
return (result.Results ?? [])[0]?.ID ?? null;
|
|
1011
|
+
}
|
|
1012
|
+
async planStageOrderStatus() {
|
|
1013
|
+
const stageID = this.PipelineStageID;
|
|
1014
|
+
if (!stageID) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
if (this.IsSaved && !this._orderJustProvisioned) {
|
|
1018
|
+
const previous = this.GetFieldByName('PipelineStageID')?.OldValue;
|
|
1019
|
+
if (previous && String(previous).toLowerCase() === String(stageID).toLowerCase()) {
|
|
1020
|
+
return null; // the stage did not move; the order is not this save's business
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* `_orderJustProvisioned` is what lets a BIRTH ask the question a MOVE asks.
|
|
1025
|
+
*
|
|
1026
|
+
* An order that has just been created has never been told anything about the deal's stage, so
|
|
1027
|
+
* the stage gets to speak once — even though nothing moved. Without this, an existing deal
|
|
1028
|
+
* sitting at a stage that declares `Quoted` acquired an order in `Draft` and stayed there until
|
|
1029
|
+
* somebody happened to drag the card, which is a state the board displays without complaint.
|
|
1030
|
+
*
|
|
1031
|
+
* It cannot loop: the flag is set only by `OrderID_EnsureObject()` inside
|
|
1032
|
+
* `provisionEmbeddedOrder()`, and cleared at the top of every `Save()`.
|
|
1033
|
+
*/
|
|
1034
|
+
const view = this.ProviderToUse;
|
|
1035
|
+
const result = await view.RunView({
|
|
1036
|
+
EntityName: STAGE_ENTITY,
|
|
1037
|
+
ExtraFilter: `ID = '${stageID}'`,
|
|
1038
|
+
ResultType: 'simple',
|
|
1039
|
+
Fields: ['OrderStatusOnEntry'],
|
|
1040
|
+
}, this.ContextCurrentUser);
|
|
1041
|
+
if (!result.Success) {
|
|
1042
|
+
// Not fatal, and deliberately so: a stage lookup that fails must not stop a rep moving a
|
|
1043
|
+
// card. It is recorded as a warning like any other thing that did not happen.
|
|
1044
|
+
this._orderStatusWarnings.push(`The order was not updated: the stage's OrderStatusOnEntry could not be read ` +
|
|
1045
|
+
`(${result.ErrorMessage ?? 'unknown error'}).`);
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
const target = (result.Results ?? [])[0]?.OrderStatusOnEntry;
|
|
1049
|
+
if (!target) {
|
|
1050
|
+
return null; // this stage says nothing about the order. The common case.
|
|
1051
|
+
}
|
|
1052
|
+
return { StageID: stageID, Target: target };
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Applies the plan to the deal's order, and NEVER lets a refusal reach the caller as a failure.
|
|
1056
|
+
*
|
|
1057
|
+
* ── WHY THE TRANSITION IS CHECKED BEFORE IT IS ATTEMPTED ──
|
|
1058
|
+
*
|
|
1059
|
+
* `CanTransition` is orders' own table of legal moves. Asking it first means the ordinary refusal —
|
|
1060
|
+
* `Voided → Quoted` on a reopened lost deal, which S-US8 says WILL happen — costs no write and
|
|
1061
|
+
* produces orders' own explanation. Attempting the save and reading the failure would also work,
|
|
1062
|
+
* but it would leave the in-memory order holding a status the database refused, and that object is
|
|
1063
|
+
* a participant in this deal's save graph.
|
|
1064
|
+
*
|
|
1065
|
+
* ── WHY THE SAVE STILL RUNS IN ITS OWN SCOPE ──
|
|
1066
|
+
*
|
|
1067
|
+
* A legal transition can still fail for reasons this app does not model — confirming books journal
|
|
1068
|
+
* entries and needs a payer. `BeginEntityTransaction` joins the ambient transaction as a savepoint,
|
|
1069
|
+
* so rolling this one back undoes the order write and leaves the deal's own transaction intact.
|
|
1070
|
+
* Without that, one order-side failure would take the stage change down with it, which is exactly
|
|
1071
|
+
* what D-OS1 forbids.
|
|
1072
|
+
*
|
|
1073
|
+
* ── THE UNSAVED ORDER ──
|
|
1074
|
+
*
|
|
1075
|
+
* A new deal's order has not been written yet, so there is nothing to transition and nothing to
|
|
1076
|
+
* roll back: setting `Status` means it is INSERTED in that state by the graph a moment later.
|
|
1077
|
+
*/
|
|
1078
|
+
async applyStageOrderStatus(plan) {
|
|
1079
|
+
const order = this.OrderID_Object;
|
|
1080
|
+
if (!order) {
|
|
1081
|
+
// No order to update. Not a refusal and not worth a warning: a deal without one predates
|
|
1082
|
+
// the embedded order, and provisioning is what fixes that, not this.
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (!order.IsSaved) {
|
|
1086
|
+
order.Status = plan.Target;
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
const from = order.Status;
|
|
1090
|
+
if (from === plan.Target) {
|
|
1091
|
+
return; // already there; a no-op write would only dirty the row
|
|
1092
|
+
}
|
|
1093
|
+
const verdict = CanTransition(from, plan.Target);
|
|
1094
|
+
if (!verdict.Allowed) {
|
|
1095
|
+
this._orderStatusWarnings.push(`The deal moved stage, but its order stayed ${from}: ${verdict.Reason ?? 'orders refused the move.'} ` +
|
|
1096
|
+
`The stage change was kept — a sales stage is not held hostage by an order-side rule.`);
|
|
1097
|
+
LogError(`DealEntityServer: stage ${plan.StageID} asked for order status '${plan.Target}' on order ` +
|
|
1098
|
+
`${order.ID} (currently '${from}') and orders refused it: ${verdict.Reason ?? 'no reason given'}. ` +
|
|
1099
|
+
'The deal\'s stage change proceeds (D-OS1).');
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const provider = this.ProviderToUse;
|
|
1103
|
+
const scope = await provider.BeginEntityTransaction();
|
|
1104
|
+
try {
|
|
1105
|
+
order.Status = plan.Target;
|
|
1106
|
+
if (await order.Save()) {
|
|
1107
|
+
await scope.Commit();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
throw new Error(order.LatestResult?.CompleteMessage ?? 'the order save was refused with no message');
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
try {
|
|
1114
|
+
await scope.Rollback();
|
|
1115
|
+
}
|
|
1116
|
+
catch (rollbackErr) {
|
|
1117
|
+
LogError(`DealEntityServer: rolling back the order status update failed: ${rollbackErr}`);
|
|
1118
|
+
}
|
|
1119
|
+
// Put the in-memory value back, or the deal's save graph will try to write the status the
|
|
1120
|
+
// database has just refused and take the whole save down with it.
|
|
1121
|
+
order.Status = from;
|
|
1122
|
+
this._orderStatusWarnings.push(`The deal moved stage, but its order stayed ${from}: the move to ${plan.Target} was legal ` +
|
|
1123
|
+
`but did not complete (${err instanceof Error ? err.message : String(err)}). The stage change was kept.`);
|
|
1124
|
+
LogError(`DealEntityServer: the order status update to '${plan.Target}' failed: ${err}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Re-establishes `Deal.Amount` from the order, as a CACHE with honest provenance.
|
|
1129
|
+
*
|
|
1130
|
+
* ── WHAT BROKE, AND WHY IT WAS INVISIBLE ──
|
|
1131
|
+
*
|
|
1132
|
+
* `Amount` was a cached `Orders.PreviewOrder` answer and `AmountSourceHash` fingerprinted the
|
|
1133
|
+
* `DealLine` set it came from. `DealLine` is retired, so the fingerprint describes a table that does
|
|
1134
|
+
* not exist and NOTHING repopulated the number. It did not go blank — it kept whatever it last held,
|
|
1135
|
+
* which is the failure mode that survives review: a dashboard and a board reporting a pipeline
|
|
1136
|
+
* figure with no relationship to the order underneath it.
|
|
1137
|
+
*
|
|
1138
|
+
* ── WHY `OrderHeader.TotalGross` AND NOT A SUM HERE ──
|
|
1139
|
+
*
|
|
1140
|
+
* Rule 1. Orders maintains `TotalGross` with its own rollup trigger (their D41 —
|
|
1141
|
+
* trigger-maintained, deliberately not a computed column), so this reads ONE number that orders
|
|
1142
|
+
* already stands behind. There is no multiplication, no addition and no rounding in this method, and
|
|
1143
|
+
* there must never be: the moment sales sums `LineTotalGross` itself, the quote and the invoice can
|
|
1144
|
+
* disagree.
|
|
1145
|
+
*
|
|
1146
|
+
* ── WHY IT IS CACHED ONTO THE DEAL ──
|
|
1147
|
+
*
|
|
1148
|
+
* The roster and the board read `Deal.Amount` as a column. Reading through the embedded order per
|
|
1149
|
+
* row would turn one roster query into one-plus-N, on the hottest read in the app. Same argument as
|
|
1150
|
+
* `OwnerEmployeeID`: a denormalised stamp exists so a list does not need a join.
|
|
1151
|
+
*
|
|
1152
|
+
* ── WHY IT RUNS LAST, INSIDE THE SCOPE ──
|
|
1153
|
+
*
|
|
1154
|
+
* `TotalGross` is trigger-maintained, so the value only reflects this save's lines AFTER
|
|
1155
|
+
* `super.Save()` has written them. Reading earlier caches the PREVIOUS line set — the exact staleness
|
|
1156
|
+
* this method exists to end. It writes with a targeted `UPDATE` rather than a second `Save()`: the
|
|
1157
|
+
* row is already written in this transaction, and re-entering `Save()` would re-run the close lock,
|
|
1158
|
+
* the stamps and the stage-event planner for a three-column cache refresh.
|
|
1159
|
+
*
|
|
1160
|
+
* ── THE PROVENANCE RULE, WHICH IS THE PART TO GET RIGHT ──
|
|
1161
|
+
*
|
|
1162
|
+
* `AmountIsComputed = 0` means a human typed this figure and is owed no explanation (L-2, the SIMPLE
|
|
1163
|
+
* deal). Such an amount is NEVER overwritten. But the column defaults to 0, so "0" alone cannot mean
|
|
1164
|
+
* hand-typed — a brand-new deal has never been touched by anyone. The distinction is whether an
|
|
1165
|
+
* amount is actually THERE:
|
|
1166
|
+
*
|
|
1167
|
+
* | `AmountIsComputed` | `Amount` | what it means | what happens |
|
|
1168
|
+
* |---|---|---|---|
|
|
1169
|
+
* | 1 | anything | a cache | refreshed |
|
|
1170
|
+
* | 0 | NULL | nobody has said | filled, and marked computed |
|
|
1171
|
+
* | 0 | a number | **a human typed it** | **left alone, always** |
|
|
1172
|
+
*
|
|
1173
|
+
* And a `TotalGross` of NULL means the order has no priced lines, so there is no answer to cache —
|
|
1174
|
+
* that leaves the amount alone too, rather than writing a zero that would read as "priced at nil".
|
|
1175
|
+
*/
|
|
1176
|
+
async refreshAmountFromOrder() {
|
|
1177
|
+
if (!this.OrderID) {
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
// A hand-typed figure. Not ours, ever.
|
|
1181
|
+
if (this.AmountIsComputed === false && this.Amount !== null && this.Amount !== undefined) {
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
const view = this.ProviderToUse;
|
|
1185
|
+
const result = await view.RunView({
|
|
1186
|
+
EntityName: ORDER_HEADER_ENTITY,
|
|
1187
|
+
ExtraFilter: `ID = '${this.OrderID}'`,
|
|
1188
|
+
ResultType: 'simple',
|
|
1189
|
+
Fields: ['TotalGross'],
|
|
1190
|
+
}, this.ContextCurrentUser);
|
|
1191
|
+
if (!result.Success) {
|
|
1192
|
+
// Not fatal: a stale amount is worse than a missing refresh, but neither is worth failing a
|
|
1193
|
+
// save the rep asked for. Recorded where the order warnings go, because that is what it is.
|
|
1194
|
+
this._orderStatusWarnings.push(`The deal saved, but its amount could not be refreshed from the order ` +
|
|
1195
|
+
`(${result.ErrorMessage ?? 'unknown error'}), so it may be stale.`);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* A FINITE NUMBER OR NOTHING — and the `Number.isFinite` is not defensive padding.
|
|
1200
|
+
*
|
|
1201
|
+
* `RunView` returns a clean `null` for a NULL decimal when the row is already committed, so a
|
|
1202
|
+
* `=== null` test looks sufficient. It is not: reading a row this transaction has only just
|
|
1203
|
+
* INSERTED went through a different path and produced a value that was neither `null` nor
|
|
1204
|
+
* `undefined` and that `mssql` then bound as NULL — so the cache wrote `Amount = NULL` while
|
|
1205
|
+
* stamping `AmountIsComputed = 1` and a source hash. A deal claiming a computed amount of
|
|
1206
|
+
* nothing, which is worse than either honest answer. SD23 is the check that found it.
|
|
1207
|
+
*
|
|
1208
|
+
* So the rule is the one a money cache should have had from the start: cache a finite number, or
|
|
1209
|
+
* do not touch the columns.
|
|
1210
|
+
*/
|
|
1211
|
+
const raw = (result.Results ?? [])[0]?.TotalGross;
|
|
1212
|
+
const total = raw === null || raw === undefined ? null : Number(raw);
|
|
1213
|
+
if (total === null || !Number.isFinite(total)) {
|
|
1214
|
+
return; // no usable answer — see the note above; this still guards the mid-transaction read
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* ── "NO PRICED LINES" IS NOT REACHABLE ON THIS SCHEMA, AND THE COMMENT ABOVE OVERCLAIMED ───────
|
|
1218
|
+
*
|
|
1219
|
+
* A review flagged that the guard above cannot cover "no priced lines", on the grounds that
|
|
1220
|
+
* orders' rollup is `SUM(ISNULL(l.LineTotal, 0))` and so returns 0 rather than NULL. Measured
|
|
1221
|
+
* against the live schema, that case cannot arise:
|
|
1222
|
+
*
|
|
1223
|
+
* · `OrderLine.UnitPrice` is **NOT NULL**, so a line cannot exist unpriced. Zero rows carry a
|
|
1224
|
+
* null total today, and none can.
|
|
1225
|
+
* · `OrderHeader.TotalGross` is **NULL** for an order with no lines — `SUM` over no rows is
|
|
1226
|
+
* NULL regardless of any inner `ISNULL` — so the `total === null` guard above already fires,
|
|
1227
|
+
* correctly, for the empty-order case.
|
|
1228
|
+
*
|
|
1229
|
+
* A guard was written here for the 0-with-lines case and then removed rather than shipped: it was
|
|
1230
|
+
* dead code for a state the schema forbids, and dead code in a money path is worse than none
|
|
1231
|
+
* because the next reader trusts it. If `UnitPrice` ever becomes nullable this becomes reachable,
|
|
1232
|
+
* and the guard to add then is "is any line priced", not "are there lines".
|
|
1233
|
+
*
|
|
1234
|
+
* `save-deal.SD28` pins the empty-order behaviour from a NULL starting amount, which is the case
|
|
1235
|
+
* SD23 does not reach.
|
|
1236
|
+
*/
|
|
1237
|
+
/**
|
|
1238
|
+
* The fingerprint, so a surface can say "stale, reprice" instead of showing an untraceable
|
|
1239
|
+
* number. It covers the ORDER and the TOTAL, which is exactly what the cache was derived from —
|
|
1240
|
+
* a reader holding the order can recompute this and compare without a second table.
|
|
1241
|
+
*/
|
|
1242
|
+
const hash = createHash('sha256').update(`${this.OrderID}|${total}`).digest('hex');
|
|
1243
|
+
if (Number(this.Amount) === Number(total) && this.AmountSourceHash === hash && this.AmountIsComputed) {
|
|
1244
|
+
return; // already current; a no-op write would only churn the row
|
|
1245
|
+
}
|
|
1246
|
+
const now = new Date();
|
|
1247
|
+
const sql = this.ProviderToUse;
|
|
1248
|
+
await sql.ExecuteSQL(`UPDATE ${SALES_SCHEMA}.Deal
|
|
1249
|
+
SET Amount = @amount, AmountIsComputed = 1, AmountComputedAt = @at, AmountSourceHash = @hash
|
|
1250
|
+
WHERE ID = @id`, { amount: total, at: now, hash, id: this.ID }, { isMutation: true, description: 'DealEntityServer: refresh Deal.Amount from OrderHeader.TotalGross' }, this.ContextCurrentUser);
|
|
1251
|
+
// The in-memory record follows, so a caller reading the entity back after `Save()` sees the
|
|
1252
|
+
// cache it just wrote rather than the value it came in with.
|
|
1253
|
+
this.Amount = total;
|
|
1254
|
+
this.AmountIsComputed = true;
|
|
1255
|
+
this.AmountComputedAt = now;
|
|
1256
|
+
this.AmountSourceHash = hash;
|
|
1257
|
+
/**
|
|
1258
|
+
* ── ...AND THE DIRTY FLAGS THOSE FOUR ASSIGNMENTS JUST SET ARE CLEARED ──────────────
|
|
1259
|
+
*
|
|
1260
|
+
* This method writes the cache with raw SQL and runs AFTER `super.Save()` has returned, so the
|
|
1261
|
+
* assignments above set dirty flags that nothing will ever clear. The record was therefore DIRTY
|
|
1262
|
+
* immediately after a save that fully succeeded — a lie about state, told to every caller that
|
|
1263
|
+
* asks the entity whether it has unsaved work: a Save button's enabled state, a batch job
|
|
1264
|
+
* skipping clean records, a guard refusing to discard unsaved edits. `SD39` pins it.
|
|
1265
|
+
*
|
|
1266
|
+
* The VALUES are correct and stay; only the dirty BASELINE moves, which is exactly what
|
|
1267
|
+
* `ResetOldValue()` is for — it sets the tracked old value to the current one. These four are
|
|
1268
|
+
* legitimate to reset because the database already holds precisely what is in memory: this
|
|
1269
|
+
* method put it there itself, in the statement above.
|
|
1270
|
+
*/
|
|
1271
|
+
for (const name of ['Amount', 'AmountIsComputed', 'AmountComputedAt', 'AmountSourceHash']) {
|
|
1272
|
+
this.GetFieldByName(name)?.ResetOldValue();
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Whether the PERSISTED status locks this deal — set by {@link checkCloseLock} on every save.
|
|
1277
|
+
*
|
|
1278
|
+
* The lock used to answer one question ("may this edit proceed?") and nothing else, so the writers
|
|
1279
|
+
* further down the save could not tell a frozen deal from a live one. `provisionEmbeddedOrder` in
|
|
1280
|
+
* particular ran unconditionally, and Description is deliberately editable while locked — so editing
|
|
1281
|
+
* the description of a closed legacy deal INSERTED an empty Draft order, rewrote `Deal.OrderID` on a
|
|
1282
|
+
* frozen row, and let the stage writer stamp that order from the CLOSING stage (`Voided`, for a lost
|
|
1283
|
+
* deal). Every one of those is a write to provenance that is supposed to be immutable.
|
|
1284
|
+
*
|
|
1285
|
+
* So the lock now reports what it found. Nothing else may recompute it: two places deciding whether a
|
|
1286
|
+
* deal is locked is how they come to disagree.
|
|
1287
|
+
*/
|
|
1288
|
+
/**
|
|
1289
|
+
* ── ONE WRITER FOR THE STAGE LOG, AND CALLERS DECLARE INSTEAD OF WRITING ────────────────────────
|
|
1290
|
+
*
|
|
1291
|
+
* `PipelineStageID` had FOUR writers by the end of the last round — this class's event appender and
|
|
1292
|
+
* defaults applier, plus `Sales.CloseDeal` and `Sales.ReopenDeal`, each hand-writing a
|
|
1293
|
+
* `DealStageEvent` and then moving the stage. The sequence was never audited whole, and all three
|
|
1294
|
+
* consequences were of the same kind:
|
|
1295
|
+
*
|
|
1296
|
+
* · **Two rows for one transition.** `stampClose` wrote its event, then set
|
|
1297
|
+
* `deal.PipelineStageID = input.ClosingStageID`, and the save that followed saw a stage change and
|
|
1298
|
+
* appended a SECOND row. `close-deal.CD4` could not see it because it never passes
|
|
1299
|
+
* `ClosingStageID` — the only input that makes the stage move. Reopen had it too, identically.
|
|
1300
|
+
* · **A self-transition.** `planStageEvent` compared stage ids case-sensitively while the two
|
|
1301
|
+
* writers beside it lowercased first, so a caller that normalised its ids got an append-only row
|
|
1302
|
+
* saying the deal moved from a stage to itself.
|
|
1303
|
+
* · **A reopen losing its probability.** `ReopenDeal` passing a `StageID` silently invoked the
|
|
1304
|
+
* DEFAULTS writer, which re-derived `Probability` from the arriving stage — after the reopen
|
|
1305
|
+
* event had already stamped the old one. The stamp said 60 and the deal said 10.
|
|
1306
|
+
*
|
|
1307
|
+
* The fix is not a fourth special case behind a wider gate. A caller now DECLARES that this save is a
|
|
1308
|
+
* governed transition and supplies the note that belongs on it; the entity remains the only thing that
|
|
1309
|
+
* writes to `DealStageEvent`, and it reads the declaration to answer the two questions the callers
|
|
1310
|
+
* were each answering for themselves:
|
|
1311
|
+
*
|
|
1312
|
+
* · **Is an event owed?** Yes if the stage moved — and also yes if a transition is declared, which
|
|
1313
|
+
* is what makes a close that does NOT move the stage still leave provenance. That case was the
|
|
1314
|
+
* reason `stampClose` wrote its own row in the first place.
|
|
1315
|
+
* · **Do the stage's defaults apply?** No. A declared transition's probability is the caller's
|
|
1316
|
+
* considered value — a close's final number, a reopen's hand-tuned one — and re-deriving it from
|
|
1317
|
+
* the pipeline would overwrite the very figure the event just stamped.
|
|
1318
|
+
*
|
|
1319
|
+
* Declared per save and cleared when it finishes, like every other per-save field here: a declaration
|
|
1320
|
+
* that outlived its save would silently suppress the defaults on the next unrelated edit.
|
|
1321
|
+
*/
|
|
1322
|
+
DeclareTransition(kind, note) {
|
|
1323
|
+
this._declaredTransition = { Kind: kind, Note: note };
|
|
1324
|
+
}
|
|
1325
|
+
/** The id of the `DealStageEvent` the last save appended, or null if it owed none. */
|
|
1326
|
+
get LastStageEventID() {
|
|
1327
|
+
return this._lastStageEventID;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* The fields that stay editable on a locked deal — read from the SHARED rule, not redeclared.
|
|
1331
|
+
*
|
|
1332
|
+
* It moved to `@mj-biz-apps/sales-entities` so the Explorer Deal form can apply the same list. A
|
|
1333
|
+
* second copy here would drift, and the drift would only ever surface as a user typing into a field
|
|
1334
|
+
* the server then refuses. See `close-lock.ts` for the reasoning; CD14 pins it to real behaviour.
|
|
1335
|
+
*/
|
|
1336
|
+
static { this.LOCK_EDITABLE_FIELDS = DEAL_FIELDS_EDITABLE_WHILE_LOCKED; }
|
|
1337
|
+
/**
|
|
1338
|
+
* Runs `body` with the close lock suppressed, and always restores it.
|
|
1339
|
+
*
|
|
1340
|
+
* The `finally` is the point: an exception inside the reopen must not leave the lock disabled for
|
|
1341
|
+
* the rest of this entity's life, which would silently turn every later save into an unguarded one.
|
|
1342
|
+
*/
|
|
1343
|
+
async BeginReopen(body) {
|
|
1344
|
+
this._reopenInProgress = true;
|
|
1345
|
+
try {
|
|
1346
|
+
return await body();
|
|
1347
|
+
}
|
|
1348
|
+
finally {
|
|
1349
|
+
this._reopenInProgress = false;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Why this save is refused, or null if it is allowed.
|
|
1354
|
+
*
|
|
1355
|
+
* KEYED ON THE **PERSISTED** STATUS, via `OldValue` — not on the in-memory one. That is what makes
|
|
1356
|
+
* the closing transition itself legal: a deal moving open → closed still has an OPEN status in the
|
|
1357
|
+
* database, so it passes, and only saves made once the deal is ALREADY closed are refused. Reading
|
|
1358
|
+
* the current value instead would make a deal impossible to close.
|
|
1359
|
+
*/
|
|
1360
|
+
async checkCloseLock() {
|
|
1361
|
+
const persistedStatusID = this.GetFieldByName('DealStatusTypeID')?.OldValue;
|
|
1362
|
+
if (!persistedStatusID) {
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
if (!(await this.statusLocksDeal(persistedStatusID))) {
|
|
1366
|
+
return null;
|
|
1367
|
+
}
|
|
1368
|
+
// FROM HERE THE DEAL IS LOCKED, whatever this particular edit turns out to be. Recorded before
|
|
1369
|
+
// the field-by-field verdict below, because the writers downstream care about the LOCK, not about
|
|
1370
|
+
// whether this one edit was permitted.
|
|
1371
|
+
this._lockedAtSave = true;
|
|
1372
|
+
const changed = this.Fields.filter((f) => f.Dirty && !DealEntityServer_1.LOCK_EDITABLE_FIELDS.has(f.Name)).map((f) => f.Name);
|
|
1373
|
+
/**
|
|
1374
|
+
* THE CHILD COLLECTIONS COUNT AS CHANGES TOO, and this half did not exist when the lock was
|
|
1375
|
+
* written — the collections did not exist either.
|
|
1376
|
+
*
|
|
1377
|
+
* A deal's lines are exactly what a contract and an order were derived from, so editing or
|
|
1378
|
+
* removing one on a closed deal falsifies the same provenance the header lock protects. But
|
|
1379
|
+
* `Lines`, `PaymentSchedule` and `Team` are COMPANIONS, not fields: they never appear in
|
|
1380
|
+
* `this.Fields`, so the header check above cannot see them. Without this the lock would refuse a
|
|
1381
|
+
* renamed deal and happily accept a deleted line, which is the more damaging edit of the two.
|
|
1382
|
+
*
|
|
1383
|
+
* Enumerated as `Companions` rather than as the three collections by name, deliberately: anything
|
|
1384
|
+
* that contributes work to this record's save is something the lock has to see, and naming them
|
|
1385
|
+
* individually would mean a collection added later is silently unprotected.
|
|
1386
|
+
*
|
|
1387
|
+
* Guarded by the same already-closed test, so the closing transition may still carry final
|
|
1388
|
+
* collection state — a close that writes its last line is legal; editing that line tomorrow is
|
|
1389
|
+
* not.
|
|
1390
|
+
*/
|
|
1391
|
+
const dirtyCollections = this.Companions
|
|
1392
|
+
.filter((c) => c.Dirty)
|
|
1393
|
+
.map((c) => c.Name);
|
|
1394
|
+
const all = [...changed, ...dirtyCollections];
|
|
1395
|
+
if (all.length === 0) {
|
|
1396
|
+
return null;
|
|
1397
|
+
}
|
|
1398
|
+
return (`this deal is closed and locked; ${all.join(', ')} cannot be changed. ` +
|
|
1399
|
+
`Reopen it through Sales.ReopenDeal, which records a reason.`);
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Whether a status carries `LocksDeal`.
|
|
1403
|
+
*
|
|
1404
|
+
* THE FLAG, never the name — a deployment may call its winning status "Signed", and this must keep
|
|
1405
|
+
* working. It is also why the lock is a property of the STATUS TYPE rather than of the stage.
|
|
1406
|
+
*/
|
|
1407
|
+
async statusLocksDeal(statusID) {
|
|
1408
|
+
const provider = this.ProviderToUse;
|
|
1409
|
+
const result = await provider.RunView({
|
|
1410
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
1411
|
+
ExtraFilter: `ID = '${statusID}'`,
|
|
1412
|
+
ResultType: 'simple',
|
|
1413
|
+
Fields: ['LocksDeal'],
|
|
1414
|
+
}, this.ContextCurrentUser);
|
|
1415
|
+
if (!result.Success) {
|
|
1416
|
+
// FAIL CLOSED. If the status cannot be read we cannot prove the deal is unlocked, and
|
|
1417
|
+
// wrongly allowing an edit to a closed deal is the more expensive mistake.
|
|
1418
|
+
LogError(`DealEntityServer.statusLocksDeal: could not read status ${statusID}: ${result.ErrorMessage}`);
|
|
1419
|
+
return true;
|
|
1420
|
+
}
|
|
1421
|
+
const row = (result.Results ?? [])[0];
|
|
1422
|
+
return row?.LocksDeal === true;
|
|
1423
|
+
}
|
|
1424
|
+
/* ── Selling company ────────────────────────────────────────────────────── */
|
|
1425
|
+
/**
|
|
1426
|
+
* Forces `Deal.CompanyID` to the pipeline's company, ignoring whatever the caller supplied.
|
|
1427
|
+
*
|
|
1428
|
+
* THE PIPELINE IS THE ONLY SOURCE THAT CAN BE RIGHT. A deal belongs to the company that owns the
|
|
1429
|
+
* pipeline it sits in, and a CHECK constraint cannot express that — it cannot reach across the
|
|
1430
|
+
* foreign key to compare the two. So it is resolved on the write path instead, on every save, for
|
|
1431
|
+
* every caller.
|
|
1432
|
+
*
|
|
1433
|
+
* An explicitly supplied value is OVERWRITTEN rather than rejected. A client that gets this wrong is
|
|
1434
|
+
* not making a choice the app should honour or argue with; it is stating something it has no standing
|
|
1435
|
+
* to know, and the correct answer is available here.
|
|
1436
|
+
*
|
|
1437
|
+
* A missing pipeline is left alone: `Validate()` already refuses a deal without one, and raising a
|
|
1438
|
+
* second, vaguer complaint here would just bury the specific message the user needs.
|
|
1439
|
+
*/
|
|
1440
|
+
async stampCompanyFromPipeline() {
|
|
1441
|
+
if (!this.PipelineID) {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
const viewProvider = this.ProviderToUse;
|
|
1445
|
+
const result = await viewProvider.RunView({
|
|
1446
|
+
EntityName: PIPELINE_ENTITY,
|
|
1447
|
+
ExtraFilter: `ID = '${this.PipelineID}'`,
|
|
1448
|
+
ResultType: 'simple',
|
|
1449
|
+
Fields: ['CompanyID'],
|
|
1450
|
+
}, this.ContextCurrentUser);
|
|
1451
|
+
if (!result.Success) {
|
|
1452
|
+
throw new Error(`DealEntityServer: could not read the pipeline's company: ${result.ErrorMessage}`);
|
|
1453
|
+
}
|
|
1454
|
+
const pipeline = (result.Results ?? [])[0];
|
|
1455
|
+
if (!pipeline?.CompanyID) {
|
|
1456
|
+
throw new Error(`DealEntityServer: pipeline ${this.PipelineID} could not be read, so the selling company is unknown.`);
|
|
1457
|
+
}
|
|
1458
|
+
this.CompanyID = pipeline.CompanyID;
|
|
1459
|
+
}
|
|
1460
|
+
/* ── Owner stamp (§5.1) ─────────────────────────────────────────────────── */
|
|
1461
|
+
/**
|
|
1462
|
+
* Maintains `Deal.OwnerEmployeeID` from whichever team member holds the owner role.
|
|
1463
|
+
*
|
|
1464
|
+
* `SetOwner` — the intent-expressing half — is on {@link DealEntity}, because the workspace's owner
|
|
1465
|
+
* picker has to express the same thing. What is server-only is this: keeping the DENORMALIZED stamp
|
|
1466
|
+
* true. `DealTeamMember` is the source of truth; the stamp exists only so "my deals" and per-rep
|
|
1467
|
+
* boards need no join, and nothing outside this hierarchy may write it.
|
|
1468
|
+
*
|
|
1469
|
+
* GUARDED ON THE COLLECTION BEING AUTHORITATIVE, which is the whole subtlety. A save that never
|
|
1470
|
+
* touched the roster has an empty, never-loaded `Team` — and deriving "no owner" from that would
|
|
1471
|
+
* silently CLEAR the stamp on every ordinary header edit. So the stamp is only rewritten when the
|
|
1472
|
+
* collection was actually loaded, or actually appended to.
|
|
1473
|
+
*
|
|
1474
|
+
* Both conditions are needed because `Add()` and `Create()` do not mark a collection loaded: a new
|
|
1475
|
+
* deal whose roster was composed in the browser arrives with `IsLoaded === false` and `Count > 0`.
|
|
1476
|
+
*/
|
|
1477
|
+
/**
|
|
1478
|
+
* Why a direct write to `OwnerEmployeeID` is refused, or null when this save is not doing that.
|
|
1479
|
+
*
|
|
1480
|
+
* ── THE HOLE THIS CLOSES, AND WHY IT WAS NOT OBVIOUS ────────────────────────────────────────
|
|
1481
|
+
*
|
|
1482
|
+
* {@link stampOwnerFromTeam} is guarded on the roster having participated in the save, and that
|
|
1483
|
+
* guard is correct — without it, an ordinary header edit would derive "no owner" from an unloaded
|
|
1484
|
+
* collection and CLEAR the stamp. But its consequence was two paths and a refusal on neither: a
|
|
1485
|
+
* save carrying the roster silently DISCARDED a hand-set stamp, and a header-only save silently
|
|
1486
|
+
* KEPT one. The second is the damaging half. It left the app able to hold a deal whose owner column
|
|
1487
|
+
* and owner-role team row name different people, reached by a plain `BaseEntity.Save()` with no
|
|
1488
|
+
* error and no warning — and the stamp exists precisely so per-rep rollups need no join, so a
|
|
1489
|
+
* rollup could disagree with the roster it was meant to shortcut. Proven against the database by
|
|
1490
|
+
* `scripts/audit-story-evidence.mjs` (E3) before this existed.
|
|
1491
|
+
*
|
|
1492
|
+
* ── WHY REFUSE RATHER THAN QUIETLY RE-DERIVE ────────────────────────────────────────────────
|
|
1493
|
+
*
|
|
1494
|
+
* Because the caller believed they were setting the owner. Correcting them in silence produces the
|
|
1495
|
+
* same wrong outcome as before — the owner is not who they said — with no way to notice. The
|
|
1496
|
+
* message names {@link DealEntity.SetOwner}, which is the operation they actually wanted.
|
|
1497
|
+
*
|
|
1498
|
+
* ── WHY `SetOwner` IS NOT CAUGHT BY THIS ────────────────────────────────────────────────────
|
|
1499
|
+
*
|
|
1500
|
+
* It calls `Team.Load()` first, so `IsLoaded` is true by the time it assigns the stamp: the roster
|
|
1501
|
+
* IS part of that save, and the server re-derives the same value from it a moment later. The two
|
|
1502
|
+
* conditions mirror `stampOwnerFromTeam`'s guard exactly, which is what keeps them from disagreeing.
|
|
1503
|
+
*/
|
|
1504
|
+
ownerStampEditRefusal() {
|
|
1505
|
+
if (this.Team.IsLoaded || this.Team.Count > 0) {
|
|
1506
|
+
return null; // the roster is part of this save; the stamp is derived from it below
|
|
1507
|
+
}
|
|
1508
|
+
if (!this.callerSuppliedValue('OwnerEmployeeID', this.OwnerEmployeeID)) {
|
|
1509
|
+
return null; // nobody supplied it. The common case.
|
|
1510
|
+
}
|
|
1511
|
+
return ('Deal.OwnerEmployeeID is a server-maintained stamp derived from the DealTeamMember holding ' +
|
|
1512
|
+
'the owner role, and cannot be set directly — a save that changed it without the roster ' +
|
|
1513
|
+
'would leave the column and the team disagreeing about who owns the deal. Use ' +
|
|
1514
|
+
'DealEntity.SetOwner(employeeID), which edits the roster and lets the stamp follow.');
|
|
1515
|
+
}
|
|
1516
|
+
async stampOwnerFromTeam() {
|
|
1517
|
+
if (!this.Team.IsLoaded && this.Team.Count === 0) {
|
|
1518
|
+
return; // the roster is not part of this save; leave the stamp alone
|
|
1519
|
+
}
|
|
1520
|
+
const ownerRoleID = await this.ResolveOwnerRoleID();
|
|
1521
|
+
const owner = this.Team.Items.find((m) => m.DealRoleID === ownerRoleID);
|
|
1522
|
+
this.OwnerEmployeeID = owner?.EmployeeID ?? null;
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
DealEntityServer = DealEntityServer_1 = __decorate([
|
|
1526
|
+
RegisterClass(BaseEntity, DEAL_ENTITY)
|
|
1527
|
+
], DealEntityServer);
|
|
1528
|
+
export { DealEntityServer };
|
|
1529
|
+
//# sourceMappingURL=DealEntityServer.js.map
|