@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,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `DealEntityServer` — the rules that need a database, on the one path every write takes.
|
|
3
|
+
*
|
|
4
|
+
* WHAT CHANGED, AND WHY THIS FILE GOT MUCH SMALLER. A deal is not one row, and until MJ v6 this class
|
|
5
|
+
* had to compose the tree itself: transient `Lines` / `PaymentSchedule` arrays, a deletion queue, a
|
|
6
|
+
* re-sequencer, and a `Save()` override that walked header → deletions → children inside an explicit
|
|
7
|
+
* `BeginTransaction` / `CommitTransaction`. All of that is now **Related Record Collections** —
|
|
8
|
+
* declared in metadata, generated onto `mjBizAppsSalesDealEntity`, and executed by MJ's
|
|
9
|
+
* `EntitySavePlan` inside one `EntityTransactionScope`. The framework does header-before-children and
|
|
10
|
+
* deletions-before-inserts because those orderings are inherent to the problem, not to this app.
|
|
11
|
+
*
|
|
12
|
+
* Deleting a hand-rolled implementation in favour of the framework's is only worth it when the
|
|
13
|
+
* framework's is at least as careful. It is: `RelatedRecordCollection.Load()` throws on a failed read
|
|
14
|
+
* AND refuses to load over unsaved children, which is the same "a failed load and an empty collection
|
|
15
|
+
* must never look alike" contract the hand-written `LoadLines` documented — plus a guard against a
|
|
16
|
+
* stray reload discarding work mid-edit, which the hand-written one did not have.
|
|
17
|
+
*
|
|
18
|
+
* WHAT REMAINS HERE IS EXACTLY WHAT NEEDS A DATABASE:
|
|
19
|
+
* - `DealNumber` — a gap-free counter taken from a locked row, inside the save's transaction.
|
|
20
|
+
* - The OWNER STAMP — `Deal.OwnerEmployeeID` derived from the `DealTeamMember` carrying the owner
|
|
21
|
+
* role, resolved from `DealRole.IsOwnerRole` rather than from any role name.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THIS FILE MUST NEVER DO. Arithmetic on money. No summing lines into `Amount`, no applying
|
|
24
|
+
* `RequestedDiscountPct`, no checking that the payment schedule adds up. `Amount` is a cached answer
|
|
25
|
+
* from `Orders.PreviewOrder` carrying its own provenance; the signed figures on a line are
|
|
26
|
+
* transcriptions.
|
|
27
|
+
*
|
|
28
|
+
* The form-shaped rules are NOT here — they are on {@link DealEntity} in `sales-entities`, which this
|
|
29
|
+
* class extends, so they run in the browser as well as on the server. Extending `DealEntity` rather
|
|
30
|
+
* than the generated entity is load-bearing: extend the generated one and every rule in that file
|
|
31
|
+
* silently stops applying on the server.
|
|
32
|
+
*
|
|
33
|
+
* - The CLOSE LOCK (S4). Once the deal's status carries `DealStatusType.LocksDeal = 1`, the header
|
|
34
|
+
* (except Description / NextStep), its lines, its instalments and its team are immutable, and
|
|
35
|
+
* reopening goes through `Sales.ReopenDeal`, which records a reason.
|
|
36
|
+
*
|
|
37
|
+
* ── STILL TO COME (deliberately not stubbed) ────────────────────────────────────────────────────
|
|
38
|
+
* - `DealStageEvent` append on ORDINARY stage transition, stamping `AmountAtTransition` and
|
|
39
|
+
* `ProbabilityAtTransition`. `Sales.CloseDeal` already stamps the CLOSE transition; consolidating
|
|
40
|
+
* the two into a single writer here is D-BD2. It must be inside the graph's transaction, which
|
|
41
|
+
* since MJ `47ff71d68b` means a scope opened around `super.Save()` rather than a graph-node
|
|
42
|
+
* branch — see the note on {@link DealEntityServer.Save}.
|
|
43
|
+
*
|
|
44
|
+
* @module @mj-biz-apps/sales-core-entities-server
|
|
45
|
+
*/
|
|
46
|
+
import { EntitySaveOptions } from '@memberjunction/core';
|
|
47
|
+
import { DealEntity } from '@mj-biz-apps/sales-entities';
|
|
48
|
+
/**
|
|
49
|
+
* The persisted state a deal held on the way OUT of a stage — what `DealStageEvent` records.
|
|
50
|
+
*
|
|
51
|
+
* A snapshot rather than a read at write time, because by then `super.Save()` has overwritten the
|
|
52
|
+
* values. See {@link DealEntityServer.planStageEvent}.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The kinds of governed transition a remote operation can declare. Both are transactional acts that own
|
|
56
|
+
* their own provenance — `Sales.CloseDeal` and `Sales.ReopenDeal`. An ordinary stage move declares
|
|
57
|
+
* nothing; it is detected, not announced.
|
|
58
|
+
*/
|
|
59
|
+
export type DealTransitionKind = 'Close' | 'Reopen';
|
|
60
|
+
export declare class DealEntityServer extends DealEntity {
|
|
61
|
+
/**
|
|
62
|
+
* Async validation always runs on this path. The base class defaults to skipping it; a deal that
|
|
63
|
+
* carries a whole graph of children is exactly the case where you want it, and silently skipping
|
|
64
|
+
* validation on the one code path every write takes would defeat the point of putting rules here.
|
|
65
|
+
*/
|
|
66
|
+
get DefaultSkipAsyncValidation(): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Prepares the deal, then hands the whole graph to the framework.
|
|
69
|
+
*
|
|
70
|
+
* ── THIS METHOD RUNS EXACTLY ONCE PER SAVE, COMPOSITE OR NOT. ──
|
|
71
|
+
*
|
|
72
|
+
* That is a guarantee of MJ's, not an accident of ours, and it is worth knowing why because it used
|
|
73
|
+
* to be false. When a deal has children, `BaseEntity.Save()` builds a save plan and routes to
|
|
74
|
+
* `saveGraph`, which opens the transaction and executes every node — **the root included** — through
|
|
75
|
+
* the private `BaseEntity.saveAsGraphNode`, which calls `_InnerSave` directly:
|
|
76
|
+
*
|
|
77
|
+
* SaveSelfOnly: (entity, opts) => entity.saveAsGraphNode(opts)
|
|
78
|
+
*
|
|
79
|
+
* `_InnerSave` is BELOW this override, so the graph never re-enters `Save()`. Preparation therefore
|
|
80
|
+
* happens once by construction, on the only call there is, and a childless deal (which never builds
|
|
81
|
+
* a plan at all) takes the same single path.
|
|
82
|
+
*
|
|
83
|
+
* ── DO NOT RE-ADD A GRAPH-NODE GUARD ──
|
|
84
|
+
*
|
|
85
|
+
* Until MJ `47ff71d68b` the graph executed the root by calling this record's public `Save()` again
|
|
86
|
+
* with `EntitySaveOptions.IsGraphNodeSave` set, so this method DID run twice and needed an early
|
|
87
|
+
* return to stay idempotent. MJ deleted that flag deliberately — *"so application code cannot skip
|
|
88
|
+
* companions by passing a public flag"* — and made the node path private. There is no successor
|
|
89
|
+
* property and none is wanted: the re-entrancy the guard defended against no longer happens.
|
|
90
|
+
*
|
|
91
|
+
* `EntitySaveOptions.SkipRelatedCollections` is NOT that successor. It suppresses the caller's own
|
|
92
|
+
* collections on the way in (orders' `OrderEntityServer` uses it to hold its lines back until the
|
|
93
|
+
* header has a key); it says nothing about who is calling. Reaching for it here would skip the
|
|
94
|
+
* deal's lines and team entirely.
|
|
95
|
+
*
|
|
96
|
+
* ── WHERE THE S4 STAGE-EVENT APPEND HAS TO GO (this changed too) ──
|
|
97
|
+
*
|
|
98
|
+
* `DealStageEvent` is append-only provenance, so a rolled-back save must not leave an event behind
|
|
99
|
+
* claiming a transition that did not happen — the append has to be inside the same transaction as
|
|
100
|
+
* the write. The old answer was "put it in the `IsGraphNodeSave` branch", because that branch ran
|
|
101
|
+
* inside the graph's transaction. **That branch is gone, and code placed here now runs BEFORE the
|
|
102
|
+
* graph opens anything.**
|
|
103
|
+
*
|
|
104
|
+
* The mechanism that still works is the one {@link DealEntityServer.saveWithinScope} uses: open a
|
|
105
|
+
* scope with `BeginEntityTransaction()`, do the work, then call `super.Save(options)` inside it. The
|
|
106
|
+
* graph's own scope JOINS an ambient transaction rather than opening a second one, so the append and
|
|
107
|
+
* every row in the graph commit or roll back together.
|
|
108
|
+
*
|
|
109
|
+
* **That append now exists** (D-BD2) and shares its scope with two other jobs. `saveWithinScope` is
|
|
110
|
+
* where the ordering between them is decided and is the only place this class opens a transaction.
|
|
111
|
+
*/
|
|
112
|
+
Save(options?: EntitySaveOptions): Promise<boolean>;
|
|
113
|
+
private saveDeclared;
|
|
114
|
+
/**
|
|
115
|
+
* The snapshot an ordinary stage move owes the append-only log, or null when the deal did not move.
|
|
116
|
+
*
|
|
117
|
+
* ── THE PERSISTED VALUES, read before the save overwrites them ──
|
|
118
|
+
*
|
|
119
|
+
* `OldValue` is what is on disk, which is the only thing that makes a transition detectable — the
|
|
120
|
+
* same discipline the close lock uses when it asks whether the deal was ALREADY locked rather than
|
|
121
|
+
* whether it is about to be.
|
|
122
|
+
*
|
|
123
|
+
* ── THE STAMPS COME FROM THE PRIOR VALUES, NOT THE NEW ONES ──
|
|
124
|
+
*
|
|
125
|
+
* That is the whole point of the table. A board drag applies the target stage's probability default,
|
|
126
|
+
* so reading the current value here would record the number the deal acquired by ARRIVING rather
|
|
127
|
+
* than the one it held on the way out — and every velocity report built on it would be quietly
|
|
128
|
+
* wrong.
|
|
129
|
+
*
|
|
130
|
+
* A brand-new deal has no prior stage and owes no event: `priorStageID` is null, so it is not a
|
|
131
|
+
* MOVE. It is a birth, and `DealStageEvent` is a log of movement.
|
|
132
|
+
*/
|
|
133
|
+
private planStageEvent;
|
|
134
|
+
/**
|
|
135
|
+
* The ONE place a save opens a transaction, and the one place the ordering is decided.
|
|
136
|
+
*
|
|
137
|
+
* Four jobs share this scope, and each has a reason to be where it is:
|
|
138
|
+
*
|
|
139
|
+
* 1. **The deal number**, BEFORE the header is written — `DealNumber` carries a filtered UNIQUE
|
|
140
|
+
* index, and assigning it afterwards would mean a second write to a row already visible. The
|
|
141
|
+
* transaction is what keeps the counter gap-free: `spAssignNextDealNumber` increments a locked
|
|
142
|
+
* row, so a save that then fails must roll the increment back.
|
|
143
|
+
* 2. **The order status**, BEFORE the header is written, because an unsaved order is INSERTED in the
|
|
144
|
+
* target status rather than created and then moved — one row, one state, no history of a status
|
|
145
|
+
* it was never really in.
|
|
146
|
+
* 3. **The stage event**, AFTER, because it is provenance for a move that has actually happened.
|
|
147
|
+
* An event for a save that then failed is worse than no event: it is a claim about history.
|
|
148
|
+
* 4. **The `Deal.Amount` cache**, LAST, because `OrderHeader.TotalGross` is trigger-maintained and
|
|
149
|
+
* only reflects this save's lines once they are written. Its trigger is the ORDER changing rather
|
|
150
|
+
* than the stage, which is why the caller decides separately whether it runs.
|
|
151
|
+
*
|
|
152
|
+
* **BOTH-OR-NEITHER, ACROSS ALL THREE.** A moved deal with no event is a hole in an append-only
|
|
153
|
+
* log. A numbered deal that does not exist consumes a number nobody can account for. And a stage
|
|
154
|
+
* change visible while the order it moved is not would be a worse outcome than either alone.
|
|
155
|
+
*
|
|
156
|
+
* `BeginEntityTransaction` JOINS an ambient transaction rather than opening a second, so a caller
|
|
157
|
+
* already in one — `Sales.CloseDeal`, or an integration check — gets a savepoint and everything
|
|
158
|
+
* still settles together.
|
|
159
|
+
*
|
|
160
|
+
* **A save with nothing to do here takes no transaction at all** (see the caller). The overwhelming
|
|
161
|
+
* majority of deal saves are edits to a field nobody is watching, and wrapping those in a scope
|
|
162
|
+
* would be a cost paid on every keystroke for a guarantee they do not need.
|
|
163
|
+
*/
|
|
164
|
+
private saveWithinScope;
|
|
165
|
+
/**
|
|
166
|
+
* Writes the one `DealStageEvent` a move owes.
|
|
167
|
+
*
|
|
168
|
+
* APPEND-ONLY — never edited, never deleted. Note what is NOT here: no close, no routing, no
|
|
169
|
+
* downstream call. A stage change is a stage change; closing is `Sales.CloseDeal` and stays an
|
|
170
|
+
* explicit act, even when the stage a deal moves into is the one a pipeline calls Signed.
|
|
171
|
+
*/
|
|
172
|
+
private appendStageEvent;
|
|
173
|
+
/**
|
|
174
|
+
* Gives a brand-new deal its embedded order, in Draft, on the deal's FIRST save.
|
|
175
|
+
*
|
|
176
|
+
* ── WHY THE ENTITY SERVER AND NOT THE WORKSPACE ──
|
|
177
|
+
*
|
|
178
|
+
* The workspace called `OrderID_EnsureObject()` from `AddLine()`, so a deal only got an order when
|
|
179
|
+
* somebody added a line THROUGH THE UI. An agent, an importer, a remote operation or a plain
|
|
180
|
+
* `BaseEntity.Save()` each produced a deal with no order at all -- and S-US4 puts the order at
|
|
181
|
+
* creation, not at first line. Same reasoning as the close lock below: a rule the UI enforces holds
|
|
182
|
+
* only until something that is not the UI writes.
|
|
183
|
+
*
|
|
184
|
+
* ── WHY FIRST SAVE AND NOT NewRecord() ──
|
|
185
|
+
*
|
|
186
|
+
* `OrderHeader.CompanyID` is NOT NULL, and a deal does not know its company until
|
|
187
|
+
* `stampCompanyFromPipeline()` has run: the caller supplies a PipelineID and the selling company is
|
|
188
|
+
* derived from it (D-4). Provisioning in `NewRecord()` would mean inventing a company or building an
|
|
189
|
+
* order that cannot be inserted. So it happens here, after the stamps, on the one call where
|
|
190
|
+
* `IsSaved` is still false.
|
|
191
|
+
*
|
|
192
|
+
* ── WHY THE GUARD IS `IsSaved`, AND NOT `IsSaved || OrderID` ──
|
|
193
|
+
*
|
|
194
|
+
* It was the second form, and that was a real bug rather than a stylistic choice. `Ensure()` assigns
|
|
195
|
+
* the new peer's key to `OrderID` immediately, so a caller that reached the order BEFORE saving --
|
|
196
|
+
* the workspace's `AddLine()`, an importer building a deal and its lines for one `Save()`, or any of
|
|
197
|
+
* the line checks in `save-deal` -- arrived here with `OrderID` already populated. The guard then
|
|
198
|
+
* returned early, the stamps below never ran, and the save died inside orders on
|
|
199
|
+
* `CompanyID cannot be null`: a NOT NULL complaint about a column two apps away from the mistake.
|
|
200
|
+
*
|
|
201
|
+
* Idempotent twice over all the same: `Ensure()` returns any existing peer, `IsSaved` means a re-save
|
|
202
|
+
* never re-provisions, and an order that is ALREADY PERSISTED is left untouched (below) so attaching
|
|
203
|
+
* an existing order stays a legitimate thing to do.
|
|
204
|
+
*
|
|
205
|
+
* ── WHAT SALES STATES, AND WHAT IT DOES NOT ──
|
|
206
|
+
*
|
|
207
|
+
* Company, type, and who to bill. NOT a status: orders' own `NewRecord()` defaults it to Draft, and
|
|
208
|
+
* restating that here would be sales asserting an order lifecycle it does not own. NOT an
|
|
209
|
+
* `OrderNumber`: the server subclass in orders mints it, which is what the diagnostic below is about.
|
|
210
|
+
*
|
|
211
|
+
* `BillToOrganizationID` and `BillToPersonID` are BOTH set, and unlike the contract case that is
|
|
212
|
+
* legal -- `OrderHeader` has no XOR across them, checked against `sys.check_constraints`. Do not
|
|
213
|
+
* carry the contract's exactly-one rule across by analogy; it is that table's rule, not a house style.
|
|
214
|
+
* `Deal.AccountID` is a `SalesAccount`, an IsA child of common's `Organization`, so its key IS an
|
|
215
|
+
* organization key; `PrimaryContactID` is a `SalesContact`, an IsA child of `Person`.
|
|
216
|
+
*/
|
|
217
|
+
private provisionEmbeddedOrder;
|
|
218
|
+
/**
|
|
219
|
+
* Turns the one failure this provisioning can cause into a message that names its cause.
|
|
220
|
+
*
|
|
221
|
+
* `OrderNumber` is NOT NULL and is minted by the SERVER subclass in orders. If that class is not
|
|
222
|
+
* registered in the process, `ClassFactory` resolves the generated `OrderHeader` instead, nothing
|
|
223
|
+
* mints a number, and the insert dies on a NOT NULL violation raised inside the save graph. What
|
|
224
|
+
* reaches the caller is `Save failed for OrderID_Object (MJ_BizApps_Orders: Order Headers): Error
|
|
225
|
+
* executing SQL` -- which names the entity, not the reason, and reads like a database fault rather
|
|
226
|
+
* than a missing import.
|
|
227
|
+
*
|
|
228
|
+
* SALES MUST NOT FIX THAT BY DEPENDING ON ORDERS' SERVER PACKAGE. Neither `sales-server` nor
|
|
229
|
+
* `sales-core-entities-server` does, and neither should start: loading another app's server classes
|
|
230
|
+
* from inside this one is how two registrations for one key end up decided by import order. MJAPI
|
|
231
|
+
* owns that -- MJ declares orders' server packages itself, which is why the running app works. A
|
|
232
|
+
* bare node harness has to call `LoadBizAppsOrdersServer()` on its own.
|
|
233
|
+
*
|
|
234
|
+
* So this cannot repair anything. It exists to stop the next person debugging the wrong layer.
|
|
235
|
+
*/
|
|
236
|
+
private explainOrderProvisioningFailure;
|
|
237
|
+
/**
|
|
238
|
+
* Warnings from the last `Save()` about what did NOT happen to the order, and why.
|
|
239
|
+
*
|
|
240
|
+
* A channel rather than a thrown error, because that is the whole ruling: *"The Deal stage is the
|
|
241
|
+
* salesperson's record of the sales process; it should never be held hostage by order-side rules"*
|
|
242
|
+
* (Andrew, D-OS1). So a refused order update is not a failure of the save — it is a fact about the
|
|
243
|
+
* save, and something has to be able to say it out loud.
|
|
244
|
+
*
|
|
245
|
+
* `Sales.ReopenDeal` and `Sales.CloseDeal` copy these into their `Issues` with
|
|
246
|
+
* `Severity: 'warning'`, which is how a caller sees them. A plain `deal.Save()` from the board's
|
|
247
|
+
* drag leaves them here and in the server log; see `DECISIONS-NEEDED.md` DN-12, because a warning
|
|
248
|
+
* only a server log carries is one a rep never reads.
|
|
249
|
+
*/
|
|
250
|
+
private readonly _orderStatusWarnings;
|
|
251
|
+
get OrderStatusWarnings(): readonly string[];
|
|
252
|
+
/**
|
|
253
|
+
* Resolves what this save's stage change asks of the order. Reads only; opens nothing.
|
|
254
|
+
*
|
|
255
|
+
* ── WHAT COUNTS AS "CHANGING STAGE" ──
|
|
256
|
+
*
|
|
257
|
+
* `OldValue` versus the current value, the same mechanism {@link checkCloseLock} keys on. A save
|
|
258
|
+
* that does not touch `PipelineStageID` plans nothing, so an ordinary edit — a rename, a nudge to
|
|
259
|
+
* `NextStep` — never reaches the order. That matters more than it looks: an order that got
|
|
260
|
+
* re-Quoted on every save of its deal would be a write amplification nobody asked for, and on a
|
|
261
|
+
* Confirmed order it would be a refusal warning on every single save.
|
|
262
|
+
*
|
|
263
|
+
* A BRAND-NEW deal also counts as entering its first stage. `CanTransition` treats a null `from` as
|
|
264
|
+
* "may be created in any legal state", so a new deal on a stage naming `Quoted` gets an order
|
|
265
|
+
* created Quoted rather than created Draft and then moved — one row, one state, no history of a
|
|
266
|
+
* status it was never really in.
|
|
267
|
+
*/
|
|
268
|
+
/**
|
|
269
|
+
* What the stage says this deal's probability and forecast category become — read BEFORE the
|
|
270
|
+
* transaction, like every other plan in this class.
|
|
271
|
+
*
|
|
272
|
+
* ── WHY THIS IS ON THE WRITE PATH AND NOT IN THE WORKSPACE ──────────────────────────────────
|
|
273
|
+
*
|
|
274
|
+
* It was in the workspace: `DealWorkspaceComponent.ApplyStageDefaults`, called from the stage picker.
|
|
275
|
+
* So a stage set by an agent, by the S6 HubSpot importer, by an Action or by any API caller got
|
|
276
|
+
* NEITHER value — the deal landed with whatever probability the caller happened to supply, or null,
|
|
277
|
+
* while the pipeline designer's answer sat unused in the stage row. Exactly the shape of the order
|
|
278
|
+
* provisioning that used to live in the workspace and now lives in `Save()`: a rule the UI enforces
|
|
279
|
+
* is a rule only the UI obeys.
|
|
280
|
+
*
|
|
281
|
+
* ── SAME TRIGGER AS THE OTHER THREE, DELIBERATELY A SEPARATE READ ───────────────────────────
|
|
282
|
+
*
|
|
283
|
+
* Keyed on `PipelineStageID` changing, like the order-status writer and the stage event. It reads the
|
|
284
|
+
* stage row a second time rather than widening `planStageOrderStatus`, and that is a considered
|
|
285
|
+
* trade: one extra read on a stage change — a rare event — against touching a method whose exact
|
|
286
|
+
* behaviour is pinned by `close-won-order.CO3`, `CO5` and three mutants. Cheap insurance beats a
|
|
287
|
+
* clever saving here.
|
|
288
|
+
*
|
|
289
|
+
* ── IT DOES NOT FIRE ON PROVISIONING, unlike the order-status writer ────────────────────────
|
|
290
|
+
*
|
|
291
|
+
* `planStageOrderStatus` also runs when an existing deal acquires an order (`_orderJustProvisioned`,
|
|
292
|
+
* SD25), because an order that has just come into existence has never been told anything. These
|
|
293
|
+
* fields are different: the deal has held them all along, and re-applying a stage default to a deal
|
|
294
|
+
* whose stage did not move would overwrite a number a rep tuned by hand.
|
|
295
|
+
*/
|
|
296
|
+
private planStageDefaults;
|
|
297
|
+
/**
|
|
298
|
+
* Fills the forecast fields from the stage, WITHOUT overwriting a value this save set deliberately.
|
|
299
|
+
*
|
|
300
|
+
* ── THE RULE IS THE AMOUNT CACHE'S RULE ─────────────────────────────────────────────────────
|
|
301
|
+
*
|
|
302
|
+
* `Deal.Amount` distinguishes a computed figure from a stated one and never overwrites the stated
|
|
303
|
+
* one (SD22). The same distinction applies here and is read the same way: a field the caller TOUCHED
|
|
304
|
+
* in this save is theirs, and a field they did not touch is the stage's to fill.
|
|
305
|
+
*
|
|
306
|
+
* `Dirty` is what expresses that. It is true only when this save changed the value, so:
|
|
307
|
+
*
|
|
308
|
+
* · A rep dragging a card sets no probability -> not dirty -> the stage's number lands.
|
|
309
|
+
* · A rep who moves the stage AND types 85 -> dirty -> 85 stands, which is the point of the
|
|
310
|
+
* workspace comment promising both stay editable.
|
|
311
|
+
* · THERE IS NO UI COPY ANY MORE. The claim that stood here — that the workspace's own
|
|
312
|
+
* `ApplyStageDefaults` set both fields before saving, so they arrived dirty and were respected,
|
|
313
|
+
* so "the UI copy and this one cannot fight" — was WRONG, in the damaging direction. Arriving
|
|
314
|
+
* dirty is precisely what made this method respect a number the UI had ALREADY overwritten.
|
|
315
|
+
* Two copies did it, the workspace's and the board's, and BD6 stayed green because it drives the
|
|
316
|
+
* entity layer and never touches either. Both are deleted; this is the only writer.
|
|
317
|
+
* · An importer that supplies a historical probability keeps it; one that supplies none inherits
|
|
318
|
+
* the pipeline's design instead of landing on null.
|
|
319
|
+
*/
|
|
320
|
+
/**
|
|
321
|
+
* Did the CALLER put this value here in this save, or is it mine to fill?
|
|
322
|
+
*
|
|
323
|
+
* ── TWO OF MJ'S SEMANTICS BITE ON CREATE, AND THE ANSWER DIFFERS BECAUSE OF THEM ─────────────
|
|
324
|
+
*
|
|
325
|
+
* On an UPDATE, `Dirty` is exactly the question: it is true when this save changed the value.
|
|
326
|
+
*
|
|
327
|
+
* On a CREATE it is useless. A first assignment does not mark a field dirty, and the first write
|
|
328
|
+
* BECOMES the `OldValue` — so `Dirty` is false for a value the caller definitely supplied, and
|
|
329
|
+
* `OldValue` is a value that was never on disk. Asking `Dirty` on a new record therefore says "not
|
|
330
|
+
* theirs" about everything the caller just set.
|
|
331
|
+
*
|
|
332
|
+
* `applyStageDefaults` already knew this and guarded with `creating = !this.IsSaved`. Two other
|
|
333
|
+
* places did not, and both were wrong in the same direction:
|
|
334
|
+
*
|
|
335
|
+
* · `planStageEvent` inferred a birth from `OldValue === null`. But a new deal whose stage is set
|
|
336
|
+
* TWICE before the first save — `NewDeal()` preselects a pipeline and its first stage, the rep
|
|
337
|
+
* then picks a different one — has a non-null `OldValue` holding a stage the deal was NEVER IN.
|
|
338
|
+
* It appended a transition event for it, into an append-only log. `BD1` asserts against exactly
|
|
339
|
+
* that and passes only because it assigns the stage once.
|
|
340
|
+
* · `ownerStampEditRefusal` asked `Dirty`, so an importer doing `NewRecord()` ->
|
|
341
|
+
* `OwnerEmployeeID = X` -> `Save()` slipped straight through the refusal and created a deal whose
|
|
342
|
+
* owner column and owner-role roster name different people — the state SD26 exists to forbid.
|
|
343
|
+
*
|
|
344
|
+
* So the question lives here now, once, and the callers ask it rather than each re-deriving it.
|
|
345
|
+
*/
|
|
346
|
+
private callerSuppliedValue;
|
|
347
|
+
private applyStageDefaults;
|
|
348
|
+
/**
|
|
349
|
+
* The status a BRAND-NEW deal starts in when nothing else supplied one.
|
|
350
|
+
*
|
|
351
|
+
* ── WHY A DEAL WITH NO STATUS IS WORSE THAN A DEAL WITH A WRONG ONE ──────────────────────────────
|
|
352
|
+
*
|
|
353
|
+
* `Deal.DealStatusTypeID` is nullable with no column default, and until now nothing in the write path
|
|
354
|
+
* required it. A deal saved without touching Status landed NULL, and every measure that reads the
|
|
355
|
+
* status through `JOIN DealStatusType` silently did not count it — a tile that under-reports rather
|
|
356
|
+
* than one that errors. It also cost a misdiagnosis: `71-lost-and-reopen` failed with
|
|
357
|
+
* `Cannot read properties of undefined (reading 'IsLost')`, which reads as a bad field and was the
|
|
358
|
+
* JOIN dropping the row.
|
|
359
|
+
*
|
|
360
|
+
* ── NEVER DERIVED FROM THE STAGE, AND THE NUMBERS SAY WHY ────────────────────────────────────
|
|
361
|
+
*
|
|
362
|
+
* The obvious-looking fix — take the stage's status — closes deals by side effect. Measured on this
|
|
363
|
+
* host: `Booked` → Won, `Signed` → Won and `Lost` → Lost all carry `LocksDeal = 1`. A deal created
|
|
364
|
+
* in one of those stages would be born closed and locked, and a board drag would book revenue. That
|
|
365
|
+
* is why {@link planStageDefaults} gates its own stage-derived status on `LocksDeal`, and why this
|
|
366
|
+
* fallback asks the VOCABULARY what an opening status is instead of asking the stage.
|
|
367
|
+
*
|
|
368
|
+
* ── THE THIRD CONDITION IS NOT REDUNDANT ────────────────────────────────────────────────────
|
|
369
|
+
*
|
|
370
|
+
* `IsActive = 1 AND IsOpen = 1 AND LocksDeal = 0`. Today every `IsOpen = 1` row also has
|
|
371
|
+
* `LocksDeal = 0`, so the third term selects nothing extra — but that is a property of the seeded
|
|
372
|
+
* data, not an invariant the schema enforces. Nothing stops a tenant declaring an open status that
|
|
373
|
+
* also locks, and since the entire hazard here is closing a deal by side effect, the guard says so
|
|
374
|
+
* rather than relying on the coincidence holding.
|
|
375
|
+
*
|
|
376
|
+
* `DisplayRank` makes the choice deterministic when a tenant defines several opening statuses.
|
|
377
|
+
*
|
|
378
|
+
* ── AND IT LEAVES NULL RATHER THAN REFUSING ──────────────────────────────────────────────────
|
|
379
|
+
*
|
|
380
|
+
* If no active, open, non-locking status exists the vocabulary is misconfigured — but refusing the
|
|
381
|
+
* save would block ALL deal creation on that tenant, which is a worse failure than the gap being
|
|
382
|
+
* closed. Returning null preserves exactly today's behaviour for that case and nothing more.
|
|
383
|
+
*/
|
|
384
|
+
private defaultOpeningStatusID;
|
|
385
|
+
private planStageOrderStatus;
|
|
386
|
+
/**
|
|
387
|
+
* Applies the plan to the deal's order, and NEVER lets a refusal reach the caller as a failure.
|
|
388
|
+
*
|
|
389
|
+
* ── WHY THE TRANSITION IS CHECKED BEFORE IT IS ATTEMPTED ──
|
|
390
|
+
*
|
|
391
|
+
* `CanTransition` is orders' own table of legal moves. Asking it first means the ordinary refusal —
|
|
392
|
+
* `Voided → Quoted` on a reopened lost deal, which S-US8 says WILL happen — costs no write and
|
|
393
|
+
* produces orders' own explanation. Attempting the save and reading the failure would also work,
|
|
394
|
+
* but it would leave the in-memory order holding a status the database refused, and that object is
|
|
395
|
+
* a participant in this deal's save graph.
|
|
396
|
+
*
|
|
397
|
+
* ── WHY THE SAVE STILL RUNS IN ITS OWN SCOPE ──
|
|
398
|
+
*
|
|
399
|
+
* A legal transition can still fail for reasons this app does not model — confirming books journal
|
|
400
|
+
* entries and needs a payer. `BeginEntityTransaction` joins the ambient transaction as a savepoint,
|
|
401
|
+
* so rolling this one back undoes the order write and leaves the deal's own transaction intact.
|
|
402
|
+
* Without that, one order-side failure would take the stage change down with it, which is exactly
|
|
403
|
+
* what D-OS1 forbids.
|
|
404
|
+
*
|
|
405
|
+
* ── THE UNSAVED ORDER ──
|
|
406
|
+
*
|
|
407
|
+
* A new deal's order has not been written yet, so there is nothing to transition and nothing to
|
|
408
|
+
* roll back: setting `Status` means it is INSERTED in that state by the graph a moment later.
|
|
409
|
+
*/
|
|
410
|
+
private applyStageOrderStatus;
|
|
411
|
+
/**
|
|
412
|
+
* Re-establishes `Deal.Amount` from the order, as a CACHE with honest provenance.
|
|
413
|
+
*
|
|
414
|
+
* ── WHAT BROKE, AND WHY IT WAS INVISIBLE ──
|
|
415
|
+
*
|
|
416
|
+
* `Amount` was a cached `Orders.PreviewOrder` answer and `AmountSourceHash` fingerprinted the
|
|
417
|
+
* `DealLine` set it came from. `DealLine` is retired, so the fingerprint describes a table that does
|
|
418
|
+
* not exist and NOTHING repopulated the number. It did not go blank — it kept whatever it last held,
|
|
419
|
+
* which is the failure mode that survives review: a dashboard and a board reporting a pipeline
|
|
420
|
+
* figure with no relationship to the order underneath it.
|
|
421
|
+
*
|
|
422
|
+
* ── WHY `OrderHeader.TotalGross` AND NOT A SUM HERE ──
|
|
423
|
+
*
|
|
424
|
+
* Rule 1. Orders maintains `TotalGross` with its own rollup trigger (their D41 —
|
|
425
|
+
* trigger-maintained, deliberately not a computed column), so this reads ONE number that orders
|
|
426
|
+
* already stands behind. There is no multiplication, no addition and no rounding in this method, and
|
|
427
|
+
* there must never be: the moment sales sums `LineTotalGross` itself, the quote and the invoice can
|
|
428
|
+
* disagree.
|
|
429
|
+
*
|
|
430
|
+
* ── WHY IT IS CACHED ONTO THE DEAL ──
|
|
431
|
+
*
|
|
432
|
+
* The roster and the board read `Deal.Amount` as a column. Reading through the embedded order per
|
|
433
|
+
* row would turn one roster query into one-plus-N, on the hottest read in the app. Same argument as
|
|
434
|
+
* `OwnerEmployeeID`: a denormalised stamp exists so a list does not need a join.
|
|
435
|
+
*
|
|
436
|
+
* ── WHY IT RUNS LAST, INSIDE THE SCOPE ──
|
|
437
|
+
*
|
|
438
|
+
* `TotalGross` is trigger-maintained, so the value only reflects this save's lines AFTER
|
|
439
|
+
* `super.Save()` has written them. Reading earlier caches the PREVIOUS line set — the exact staleness
|
|
440
|
+
* this method exists to end. It writes with a targeted `UPDATE` rather than a second `Save()`: the
|
|
441
|
+
* row is already written in this transaction, and re-entering `Save()` would re-run the close lock,
|
|
442
|
+
* the stamps and the stage-event planner for a three-column cache refresh.
|
|
443
|
+
*
|
|
444
|
+
* ── THE PROVENANCE RULE, WHICH IS THE PART TO GET RIGHT ──
|
|
445
|
+
*
|
|
446
|
+
* `AmountIsComputed = 0` means a human typed this figure and is owed no explanation (L-2, the SIMPLE
|
|
447
|
+
* deal). Such an amount is NEVER overwritten. But the column defaults to 0, so "0" alone cannot mean
|
|
448
|
+
* hand-typed — a brand-new deal has never been touched by anyone. The distinction is whether an
|
|
449
|
+
* amount is actually THERE:
|
|
450
|
+
*
|
|
451
|
+
* | `AmountIsComputed` | `Amount` | what it means | what happens |
|
|
452
|
+
* |---|---|---|---|
|
|
453
|
+
* | 1 | anything | a cache | refreshed |
|
|
454
|
+
* | 0 | NULL | nobody has said | filled, and marked computed |
|
|
455
|
+
* | 0 | a number | **a human typed it** | **left alone, always** |
|
|
456
|
+
*
|
|
457
|
+
* And a `TotalGross` of NULL means the order has no priced lines, so there is no answer to cache —
|
|
458
|
+
* that leaves the amount alone too, rather than writing a zero that would read as "priced at nil".
|
|
459
|
+
*/
|
|
460
|
+
private refreshAmountFromOrder;
|
|
461
|
+
/**
|
|
462
|
+
* Set only for the duration of {@link BeginReopen}, which is the ONE audited path through the lock.
|
|
463
|
+
*
|
|
464
|
+
* Deliberately NOT public: a caller that could set this could bypass the lock, which would make the
|
|
465
|
+
* lock advisory. `Sales.ReopenDeal` records a reason; nothing else may get through.
|
|
466
|
+
*/
|
|
467
|
+
private _reopenInProgress;
|
|
468
|
+
/**
|
|
469
|
+
* True when THIS save built the deal's order, rather than finding one already there.
|
|
470
|
+
*
|
|
471
|
+
* It exists so the stage's `OrderStatusOnEntry` can be applied to an order that has just come into
|
|
472
|
+
* existence. The status writer otherwise keys on `PipelineStageID` CHANGING, which is right for a
|
|
473
|
+
* move and wrong for a birth: a deal that is already at Proposal when its order is provisioned
|
|
474
|
+
* never moved, so nothing asked the stage what the order should be, and the order stayed Draft
|
|
475
|
+
* while its stage plainly declared Quoted. Found by the story audit against seeded data
|
|
476
|
+
* (`DEAL-9003`), and it would have hit every deal the HubSpot import lands past Proposal.
|
|
477
|
+
*/
|
|
478
|
+
private _orderJustProvisioned;
|
|
479
|
+
/**
|
|
480
|
+
* Whether the PERSISTED status locks this deal — set by {@link checkCloseLock} on every save.
|
|
481
|
+
*
|
|
482
|
+
* The lock used to answer one question ("may this edit proceed?") and nothing else, so the writers
|
|
483
|
+
* further down the save could not tell a frozen deal from a live one. `provisionEmbeddedOrder` in
|
|
484
|
+
* particular ran unconditionally, and Description is deliberately editable while locked — so editing
|
|
485
|
+
* the description of a closed legacy deal INSERTED an empty Draft order, rewrote `Deal.OrderID` on a
|
|
486
|
+
* frozen row, and let the stage writer stamp that order from the CLOSING stage (`Voided`, for a lost
|
|
487
|
+
* deal). Every one of those is a write to provenance that is supposed to be immutable.
|
|
488
|
+
*
|
|
489
|
+
* So the lock now reports what it found. Nothing else may recompute it: two places deciding whether a
|
|
490
|
+
* deal is locked is how they come to disagree.
|
|
491
|
+
*/
|
|
492
|
+
/**
|
|
493
|
+
* ── ONE WRITER FOR THE STAGE LOG, AND CALLERS DECLARE INSTEAD OF WRITING ────────────────────────
|
|
494
|
+
*
|
|
495
|
+
* `PipelineStageID` had FOUR writers by the end of the last round — this class's event appender and
|
|
496
|
+
* defaults applier, plus `Sales.CloseDeal` and `Sales.ReopenDeal`, each hand-writing a
|
|
497
|
+
* `DealStageEvent` and then moving the stage. The sequence was never audited whole, and all three
|
|
498
|
+
* consequences were of the same kind:
|
|
499
|
+
*
|
|
500
|
+
* · **Two rows for one transition.** `stampClose` wrote its event, then set
|
|
501
|
+
* `deal.PipelineStageID = input.ClosingStageID`, and the save that followed saw a stage change and
|
|
502
|
+
* appended a SECOND row. `close-deal.CD4` could not see it because it never passes
|
|
503
|
+
* `ClosingStageID` — the only input that makes the stage move. Reopen had it too, identically.
|
|
504
|
+
* · **A self-transition.** `planStageEvent` compared stage ids case-sensitively while the two
|
|
505
|
+
* writers beside it lowercased first, so a caller that normalised its ids got an append-only row
|
|
506
|
+
* saying the deal moved from a stage to itself.
|
|
507
|
+
* · **A reopen losing its probability.** `ReopenDeal` passing a `StageID` silently invoked the
|
|
508
|
+
* DEFAULTS writer, which re-derived `Probability` from the arriving stage — after the reopen
|
|
509
|
+
* event had already stamped the old one. The stamp said 60 and the deal said 10.
|
|
510
|
+
*
|
|
511
|
+
* The fix is not a fourth special case behind a wider gate. A caller now DECLARES that this save is a
|
|
512
|
+
* governed transition and supplies the note that belongs on it; the entity remains the only thing that
|
|
513
|
+
* writes to `DealStageEvent`, and it reads the declaration to answer the two questions the callers
|
|
514
|
+
* were each answering for themselves:
|
|
515
|
+
*
|
|
516
|
+
* · **Is an event owed?** Yes if the stage moved — and also yes if a transition is declared, which
|
|
517
|
+
* is what makes a close that does NOT move the stage still leave provenance. That case was the
|
|
518
|
+
* reason `stampClose` wrote its own row in the first place.
|
|
519
|
+
* · **Do the stage's defaults apply?** No. A declared transition's probability is the caller's
|
|
520
|
+
* considered value — a close's final number, a reopen's hand-tuned one — and re-deriving it from
|
|
521
|
+
* the pipeline would overwrite the very figure the event just stamped.
|
|
522
|
+
*
|
|
523
|
+
* Declared per save and cleared when it finishes, like every other per-save field here: a declaration
|
|
524
|
+
* that outlived its save would silently suppress the defaults on the next unrelated edit.
|
|
525
|
+
*/
|
|
526
|
+
DeclareTransition(kind: DealTransitionKind, note: string | null): void;
|
|
527
|
+
/** The id of the `DealStageEvent` the last save appended, or null if it owed none. */
|
|
528
|
+
get LastStageEventID(): string | null;
|
|
529
|
+
private _declaredTransition;
|
|
530
|
+
private _lastStageEventID;
|
|
531
|
+
private _lockedAtSave;
|
|
532
|
+
/**
|
|
533
|
+
* The fields that stay editable on a locked deal — read from the SHARED rule, not redeclared.
|
|
534
|
+
*
|
|
535
|
+
* It moved to `@mj-biz-apps/sales-entities` so the Explorer Deal form can apply the same list. A
|
|
536
|
+
* second copy here would drift, and the drift would only ever surface as a user typing into a field
|
|
537
|
+
* the server then refuses. See `close-lock.ts` for the reasoning; CD14 pins it to real behaviour.
|
|
538
|
+
*/
|
|
539
|
+
private static readonly LOCK_EDITABLE_FIELDS;
|
|
540
|
+
/**
|
|
541
|
+
* Runs `body` with the close lock suppressed, and always restores it.
|
|
542
|
+
*
|
|
543
|
+
* The `finally` is the point: an exception inside the reopen must not leave the lock disabled for
|
|
544
|
+
* the rest of this entity's life, which would silently turn every later save into an unguarded one.
|
|
545
|
+
*/
|
|
546
|
+
BeginReopen<T>(body: () => Promise<T>): Promise<T>;
|
|
547
|
+
/**
|
|
548
|
+
* Why this save is refused, or null if it is allowed.
|
|
549
|
+
*
|
|
550
|
+
* KEYED ON THE **PERSISTED** STATUS, via `OldValue` — not on the in-memory one. That is what makes
|
|
551
|
+
* the closing transition itself legal: a deal moving open → closed still has an OPEN status in the
|
|
552
|
+
* database, so it passes, and only saves made once the deal is ALREADY closed are refused. Reading
|
|
553
|
+
* the current value instead would make a deal impossible to close.
|
|
554
|
+
*/
|
|
555
|
+
private checkCloseLock;
|
|
556
|
+
/**
|
|
557
|
+
* Whether a status carries `LocksDeal`.
|
|
558
|
+
*
|
|
559
|
+
* THE FLAG, never the name — a deployment may call its winning status "Signed", and this must keep
|
|
560
|
+
* working. It is also why the lock is a property of the STATUS TYPE rather than of the stage.
|
|
561
|
+
*/
|
|
562
|
+
private statusLocksDeal;
|
|
563
|
+
/**
|
|
564
|
+
* Forces `Deal.CompanyID` to the pipeline's company, ignoring whatever the caller supplied.
|
|
565
|
+
*
|
|
566
|
+
* THE PIPELINE IS THE ONLY SOURCE THAT CAN BE RIGHT. A deal belongs to the company that owns the
|
|
567
|
+
* pipeline it sits in, and a CHECK constraint cannot express that — it cannot reach across the
|
|
568
|
+
* foreign key to compare the two. So it is resolved on the write path instead, on every save, for
|
|
569
|
+
* every caller.
|
|
570
|
+
*
|
|
571
|
+
* An explicitly supplied value is OVERWRITTEN rather than rejected. A client that gets this wrong is
|
|
572
|
+
* not making a choice the app should honour or argue with; it is stating something it has no standing
|
|
573
|
+
* to know, and the correct answer is available here.
|
|
574
|
+
*
|
|
575
|
+
* A missing pipeline is left alone: `Validate()` already refuses a deal without one, and raising a
|
|
576
|
+
* second, vaguer complaint here would just bury the specific message the user needs.
|
|
577
|
+
*/
|
|
578
|
+
private stampCompanyFromPipeline;
|
|
579
|
+
/**
|
|
580
|
+
* Maintains `Deal.OwnerEmployeeID` from whichever team member holds the owner role.
|
|
581
|
+
*
|
|
582
|
+
* `SetOwner` — the intent-expressing half — is on {@link DealEntity}, because the workspace's owner
|
|
583
|
+
* picker has to express the same thing. What is server-only is this: keeping the DENORMALIZED stamp
|
|
584
|
+
* true. `DealTeamMember` is the source of truth; the stamp exists only so "my deals" and per-rep
|
|
585
|
+
* boards need no join, and nothing outside this hierarchy may write it.
|
|
586
|
+
*
|
|
587
|
+
* GUARDED ON THE COLLECTION BEING AUTHORITATIVE, which is the whole subtlety. A save that never
|
|
588
|
+
* touched the roster has an empty, never-loaded `Team` — and deriving "no owner" from that would
|
|
589
|
+
* silently CLEAR the stamp on every ordinary header edit. So the stamp is only rewritten when the
|
|
590
|
+
* collection was actually loaded, or actually appended to.
|
|
591
|
+
*
|
|
592
|
+
* Both conditions are needed because `Add()` and `Create()` do not mark a collection loaded: a new
|
|
593
|
+
* deal whose roster was composed in the browser arrives with `IsLoaded === false` and `Count > 0`.
|
|
594
|
+
*/
|
|
595
|
+
/**
|
|
596
|
+
* Why a direct write to `OwnerEmployeeID` is refused, or null when this save is not doing that.
|
|
597
|
+
*
|
|
598
|
+
* ── THE HOLE THIS CLOSES, AND WHY IT WAS NOT OBVIOUS ────────────────────────────────────────
|
|
599
|
+
*
|
|
600
|
+
* {@link stampOwnerFromTeam} is guarded on the roster having participated in the save, and that
|
|
601
|
+
* guard is correct — without it, an ordinary header edit would derive "no owner" from an unloaded
|
|
602
|
+
* collection and CLEAR the stamp. But its consequence was two paths and a refusal on neither: a
|
|
603
|
+
* save carrying the roster silently DISCARDED a hand-set stamp, and a header-only save silently
|
|
604
|
+
* KEPT one. The second is the damaging half. It left the app able to hold a deal whose owner column
|
|
605
|
+
* and owner-role team row name different people, reached by a plain `BaseEntity.Save()` with no
|
|
606
|
+
* error and no warning — and the stamp exists precisely so per-rep rollups need no join, so a
|
|
607
|
+
* rollup could disagree with the roster it was meant to shortcut. Proven against the database by
|
|
608
|
+
* `scripts/audit-story-evidence.mjs` (E3) before this existed.
|
|
609
|
+
*
|
|
610
|
+
* ── WHY REFUSE RATHER THAN QUIETLY RE-DERIVE ────────────────────────────────────────────────
|
|
611
|
+
*
|
|
612
|
+
* Because the caller believed they were setting the owner. Correcting them in silence produces the
|
|
613
|
+
* same wrong outcome as before — the owner is not who they said — with no way to notice. The
|
|
614
|
+
* message names {@link DealEntity.SetOwner}, which is the operation they actually wanted.
|
|
615
|
+
*
|
|
616
|
+
* ── WHY `SetOwner` IS NOT CAUGHT BY THIS ────────────────────────────────────────────────────
|
|
617
|
+
*
|
|
618
|
+
* It calls `Team.Load()` first, so `IsLoaded` is true by the time it assigns the stamp: the roster
|
|
619
|
+
* IS part of that save, and the server re-derives the same value from it a moment later. The two
|
|
620
|
+
* conditions mirror `stampOwnerFromTeam`'s guard exactly, which is what keeps them from disagreeing.
|
|
621
|
+
*/
|
|
622
|
+
private ownerStampEditRefusal;
|
|
623
|
+
private stampOwnerFromTeam;
|
|
624
|
+
}
|
|
625
|
+
//# sourceMappingURL=DealEntityServer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DealEntityServer.d.ts","sourceRoot":"","sources":["../src/DealEntityServer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,OAAO,EAGH,iBAAiB,EAIpB,MAAM,sBAAsB,CAAC;AAQ9B,OAAO,EAEH,UAAU,EAEb,MAAM,6BAA6B,CAAC;AA0DrC;;;;;GAKG;AACH;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,OAAO,GAAG,QAAQ,CAAC;AAqBpD,qBACa,gBAAiB,SAAQ,UAAU;IAC5C;;;;OAIG;IACH,IAAoB,0BAA0B,IAAI,OAAO,CAExD;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4CG;IACmB,IAAI,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC;YAyB3D,YAAY;IAmL1B;;;;;;;;;;;;;;;;;;OAkBG;IACH,OAAO,CAAC,cAAc;IAmDtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;YACW,eAAe;IAyI7B;;;;;;OAMG;YACW,gBAAgB;IAqD9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2CG;IACH,OAAO,CAAC,sBAAsB;IAmC9B;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,+BAA+B;IAgBvC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAgB;IAErD,IAAW,mBAAmB,IAAI,SAAS,MAAM,EAAE,CAElD;IAED;;;;;;;;;;;;;;;OAeG;IACH;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;YACW,iBAAiB;IA2F/B;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,kBAAkB;IAmE1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAmCG;YACW,sBAAsB;YAoBtB,oBAAoB;IAmDlC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;YACW,qBAAqB;IA2DnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgDG;YACW,sBAAsB;IA8HpC;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB,CAAS;IAElC;;;;;;;;;OASG;IACH,OAAO,CAAC,qBAAqB,CAAS;IAEtC;;;;;;;;;;;;OAYG;IACH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACI,iBAAiB,CAAC,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAI7E,sFAAsF;IACtF,IAAW,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAE3C;IAED,OAAO,CAAC,mBAAmB,CAAmC;IAC9D,OAAO,CAAC,iBAAiB,CAAuB;IAChD,OAAO,CAAC,aAAa,CAAS;IAE9B;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,oBAAoB,CAAqC;IAEjF;;;;;OAKG;IACU,WAAW,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAS/D;;;;;;;OAOG;YACW,cAAc;IAkD5B;;;;;OAKG;YACW,eAAe;IAuB7B;;;;;;;;;;;;;;OAcG;YACW,wBAAwB;IA8BtC;;;;;;;;;;;;;;;OAeG;IACH;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,OAAO,CAAC,qBAAqB;YAef,kBAAkB;CASnC"}
|