@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.
Files changed (106) hide show
  1. package/LICENSE +183 -0
  2. package/dist/CloseDealOperation.d.ts +176 -0
  3. package/dist/CloseDealOperation.d.ts.map +1 -0
  4. package/dist/CloseDealOperation.js +1107 -0
  5. package/dist/CloseDealOperation.js.map +1 -0
  6. package/dist/CloseWonTaskService.d.ts +249 -0
  7. package/dist/CloseWonTaskService.d.ts.map +1 -0
  8. package/dist/CloseWonTaskService.js +460 -0
  9. package/dist/CloseWonTaskService.js.map +1 -0
  10. package/dist/DealEntityServer.d.ts +625 -0
  11. package/dist/DealEntityServer.d.ts.map +1 -0
  12. package/dist/DealEntityServer.js +1529 -0
  13. package/dist/DealEntityServer.js.map +1 -0
  14. package/dist/LiveContractsSeam.d.ts +212 -0
  15. package/dist/LiveContractsSeam.d.ts.map +1 -0
  16. package/dist/LiveContractsSeam.js +362 -0
  17. package/dist/LiveContractsSeam.js.map +1 -0
  18. package/dist/LiveOrdersSeam.d.ts +68 -0
  19. package/dist/LiveOrdersSeam.d.ts.map +1 -0
  20. package/dist/LiveOrdersSeam.js +80 -0
  21. package/dist/LiveOrdersSeam.js.map +1 -0
  22. package/dist/SaveDealOperation.d.ts +45 -0
  23. package/dist/SaveDealOperation.d.ts.map +1 -0
  24. package/dist/SaveDealOperation.js +268 -0
  25. package/dist/SaveDealOperation.js.map +1 -0
  26. package/dist/SequenceService.d.ts +39 -0
  27. package/dist/SequenceService.d.ts.map +1 -0
  28. package/dist/SequenceService.js +44 -0
  29. package/dist/SequenceService.js.map +1 -0
  30. package/dist/activities/ActivityIngestService.d.ts +111 -0
  31. package/dist/activities/ActivityIngestService.d.ts.map +1 -0
  32. package/dist/activities/ActivityIngestService.js +485 -0
  33. package/dist/activities/ActivityIngestService.js.map +1 -0
  34. package/dist/activities/ActivityReader.d.ts +68 -0
  35. package/dist/activities/ActivityReader.d.ts.map +1 -0
  36. package/dist/activities/ActivityReader.js +147 -0
  37. package/dist/activities/ActivityReader.js.map +1 -0
  38. package/dist/activities/ActivitySource.d.ts +142 -0
  39. package/dist/activities/ActivitySource.d.ts.map +1 -0
  40. package/dist/activities/ActivitySource.js +2 -0
  41. package/dist/activities/ActivitySource.js.map +1 -0
  42. package/dist/activities/ActivitySyncJob.d.ts +72 -0
  43. package/dist/activities/ActivitySyncJob.d.ts.map +1 -0
  44. package/dist/activities/ActivitySyncJob.js +146 -0
  45. package/dist/activities/ActivitySyncJob.js.map +1 -0
  46. package/dist/activities/ActivityWriterService.d.ts +119 -0
  47. package/dist/activities/ActivityWriterService.d.ts.map +1 -0
  48. package/dist/activities/ActivityWriterService.js +270 -0
  49. package/dist/activities/ActivityWriterService.js.map +1 -0
  50. package/dist/activities/DealMatcher.d.ts +58 -0
  51. package/dist/activities/DealMatcher.d.ts.map +1 -0
  52. package/dist/activities/DealMatcher.js +109 -0
  53. package/dist/activities/DealMatcher.js.map +1 -0
  54. package/dist/activities/FixtureActivitySource.d.ts +37 -0
  55. package/dist/activities/FixtureActivitySource.d.ts.map +1 -0
  56. package/dist/activities/FixtureActivitySource.js +58 -0
  57. package/dist/activities/FixtureActivitySource.js.map +1 -0
  58. package/dist/activities/GraphMessageMapper.d.ts +47 -0
  59. package/dist/activities/GraphMessageMapper.d.ts.map +1 -0
  60. package/dist/activities/GraphMessageMapper.js +141 -0
  61. package/dist/activities/GraphMessageMapper.js.map +1 -0
  62. package/dist/activities/ImportedGraphActivitySource.d.ts +16 -0
  63. package/dist/activities/ImportedGraphActivitySource.d.ts.map +1 -0
  64. package/dist/activities/ImportedGraphActivitySource.js +84 -0
  65. package/dist/activities/ImportedGraphActivitySource.js.map +1 -0
  66. package/dist/activities/MSGraphActivitySource.d.ts +67 -0
  67. package/dist/activities/MSGraphActivitySource.d.ts.map +1 -0
  68. package/dist/activities/MSGraphActivitySource.js +186 -0
  69. package/dist/activities/MSGraphActivitySource.js.map +1 -0
  70. package/dist/activities/MSGraphCalendarSource.d.ts +72 -0
  71. package/dist/activities/MSGraphCalendarSource.d.ts.map +1 -0
  72. package/dist/activities/MSGraphCalendarSource.js +225 -0
  73. package/dist/activities/MSGraphCalendarSource.js.map +1 -0
  74. package/dist/activities/RelevanceFilter.d.ts +101 -0
  75. package/dist/activities/RelevanceFilter.d.ts.map +1 -0
  76. package/dist/activities/RelevanceFilter.js +148 -0
  77. package/dist/activities/RelevanceFilter.js.map +1 -0
  78. package/dist/activities/activity-vocabulary.d.ts +103 -0
  79. package/dist/activities/activity-vocabulary.d.ts.map +1 -0
  80. package/dist/activities/activity-vocabulary.js +79 -0
  81. package/dist/activities/activity-vocabulary.js.map +1 -0
  82. package/dist/forecast/FixtureForecastSource.d.ts +22 -0
  83. package/dist/forecast/FixtureForecastSource.d.ts.map +1 -0
  84. package/dist/forecast/FixtureForecastSource.js +16 -0
  85. package/dist/forecast/FixtureForecastSource.js.map +1 -0
  86. package/dist/forecast/ForecastSnapshotJob.d.ts +78 -0
  87. package/dist/forecast/ForecastSnapshotJob.d.ts.map +1 -0
  88. package/dist/forecast/ForecastSnapshotJob.js +225 -0
  89. package/dist/forecast/ForecastSnapshotJob.js.map +1 -0
  90. package/dist/forecast/ForecastSource.d.ts +74 -0
  91. package/dist/forecast/ForecastSource.d.ts.map +1 -0
  92. package/dist/forecast/ForecastSource.js +25 -0
  93. package/dist/forecast/ForecastSource.js.map +1 -0
  94. package/dist/forecast/QueryForecastSource.d.ts +76 -0
  95. package/dist/forecast/QueryForecastSource.d.ts.map +1 -0
  96. package/dist/forecast/QueryForecastSource.js +164 -0
  97. package/dist/forecast/QueryForecastSource.js.map +1 -0
  98. package/dist/index.d.ts +117 -0
  99. package/dist/index.d.ts.map +1 -0
  100. package/dist/index.js +125 -0
  101. package/dist/index.js.map +1 -0
  102. package/dist/orders-availability.d.ts +15 -0
  103. package/dist/orders-availability.d.ts.map +1 -0
  104. package/dist/orders-availability.js +47 -0
  105. package/dist/orders-availability.js.map +1 -0
  106. 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