@mj-biz-apps/sales-core-entities-server 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +183 -0
- package/dist/CloseDealOperation.d.ts +176 -0
- package/dist/CloseDealOperation.d.ts.map +1 -0
- package/dist/CloseDealOperation.js +1107 -0
- package/dist/CloseDealOperation.js.map +1 -0
- package/dist/CloseWonTaskService.d.ts +249 -0
- package/dist/CloseWonTaskService.d.ts.map +1 -0
- package/dist/CloseWonTaskService.js +460 -0
- package/dist/CloseWonTaskService.js.map +1 -0
- package/dist/DealEntityServer.d.ts +625 -0
- package/dist/DealEntityServer.d.ts.map +1 -0
- package/dist/DealEntityServer.js +1529 -0
- package/dist/DealEntityServer.js.map +1 -0
- package/dist/LiveContractsSeam.d.ts +212 -0
- package/dist/LiveContractsSeam.d.ts.map +1 -0
- package/dist/LiveContractsSeam.js +362 -0
- package/dist/LiveContractsSeam.js.map +1 -0
- package/dist/LiveOrdersSeam.d.ts +68 -0
- package/dist/LiveOrdersSeam.d.ts.map +1 -0
- package/dist/LiveOrdersSeam.js +80 -0
- package/dist/LiveOrdersSeam.js.map +1 -0
- package/dist/SaveDealOperation.d.ts +45 -0
- package/dist/SaveDealOperation.d.ts.map +1 -0
- package/dist/SaveDealOperation.js +268 -0
- package/dist/SaveDealOperation.js.map +1 -0
- package/dist/SequenceService.d.ts +39 -0
- package/dist/SequenceService.d.ts.map +1 -0
- package/dist/SequenceService.js +44 -0
- package/dist/SequenceService.js.map +1 -0
- package/dist/activities/ActivityIngestService.d.ts +111 -0
- package/dist/activities/ActivityIngestService.d.ts.map +1 -0
- package/dist/activities/ActivityIngestService.js +485 -0
- package/dist/activities/ActivityIngestService.js.map +1 -0
- package/dist/activities/ActivityReader.d.ts +68 -0
- package/dist/activities/ActivityReader.d.ts.map +1 -0
- package/dist/activities/ActivityReader.js +147 -0
- package/dist/activities/ActivityReader.js.map +1 -0
- package/dist/activities/ActivitySource.d.ts +142 -0
- package/dist/activities/ActivitySource.d.ts.map +1 -0
- package/dist/activities/ActivitySource.js +2 -0
- package/dist/activities/ActivitySource.js.map +1 -0
- package/dist/activities/ActivitySyncJob.d.ts +72 -0
- package/dist/activities/ActivitySyncJob.d.ts.map +1 -0
- package/dist/activities/ActivitySyncJob.js +146 -0
- package/dist/activities/ActivitySyncJob.js.map +1 -0
- package/dist/activities/ActivityWriterService.d.ts +119 -0
- package/dist/activities/ActivityWriterService.d.ts.map +1 -0
- package/dist/activities/ActivityWriterService.js +270 -0
- package/dist/activities/ActivityWriterService.js.map +1 -0
- package/dist/activities/DealMatcher.d.ts +58 -0
- package/dist/activities/DealMatcher.d.ts.map +1 -0
- package/dist/activities/DealMatcher.js +109 -0
- package/dist/activities/DealMatcher.js.map +1 -0
- package/dist/activities/FixtureActivitySource.d.ts +37 -0
- package/dist/activities/FixtureActivitySource.d.ts.map +1 -0
- package/dist/activities/FixtureActivitySource.js +58 -0
- package/dist/activities/FixtureActivitySource.js.map +1 -0
- package/dist/activities/GraphMessageMapper.d.ts +47 -0
- package/dist/activities/GraphMessageMapper.d.ts.map +1 -0
- package/dist/activities/GraphMessageMapper.js +141 -0
- package/dist/activities/GraphMessageMapper.js.map +1 -0
- package/dist/activities/ImportedGraphActivitySource.d.ts +16 -0
- package/dist/activities/ImportedGraphActivitySource.d.ts.map +1 -0
- package/dist/activities/ImportedGraphActivitySource.js +84 -0
- package/dist/activities/ImportedGraphActivitySource.js.map +1 -0
- package/dist/activities/MSGraphActivitySource.d.ts +67 -0
- package/dist/activities/MSGraphActivitySource.d.ts.map +1 -0
- package/dist/activities/MSGraphActivitySource.js +186 -0
- package/dist/activities/MSGraphActivitySource.js.map +1 -0
- package/dist/activities/MSGraphCalendarSource.d.ts +72 -0
- package/dist/activities/MSGraphCalendarSource.d.ts.map +1 -0
- package/dist/activities/MSGraphCalendarSource.js +225 -0
- package/dist/activities/MSGraphCalendarSource.js.map +1 -0
- package/dist/activities/RelevanceFilter.d.ts +101 -0
- package/dist/activities/RelevanceFilter.d.ts.map +1 -0
- package/dist/activities/RelevanceFilter.js +148 -0
- package/dist/activities/RelevanceFilter.js.map +1 -0
- package/dist/activities/activity-vocabulary.d.ts +103 -0
- package/dist/activities/activity-vocabulary.d.ts.map +1 -0
- package/dist/activities/activity-vocabulary.js +79 -0
- package/dist/activities/activity-vocabulary.js.map +1 -0
- package/dist/forecast/FixtureForecastSource.d.ts +22 -0
- package/dist/forecast/FixtureForecastSource.d.ts.map +1 -0
- package/dist/forecast/FixtureForecastSource.js +16 -0
- package/dist/forecast/FixtureForecastSource.js.map +1 -0
- package/dist/forecast/ForecastSnapshotJob.d.ts +78 -0
- package/dist/forecast/ForecastSnapshotJob.d.ts.map +1 -0
- package/dist/forecast/ForecastSnapshotJob.js +225 -0
- package/dist/forecast/ForecastSnapshotJob.js.map +1 -0
- package/dist/forecast/ForecastSource.d.ts +74 -0
- package/dist/forecast/ForecastSource.d.ts.map +1 -0
- package/dist/forecast/ForecastSource.js +25 -0
- package/dist/forecast/ForecastSource.js.map +1 -0
- package/dist/forecast/QueryForecastSource.d.ts +76 -0
- package/dist/forecast/QueryForecastSource.d.ts.map +1 -0
- package/dist/forecast/QueryForecastSource.js +164 -0
- package/dist/forecast/QueryForecastSource.js.map +1 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/orders-availability.d.ts +15 -0
- package/dist/orders-availability.d.ts.map +1 -0
- package/dist/orders-availability.js +47 -0
- package/dist/orders-availability.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,1107 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* @fileoverview `Sales.CloseDeal` and `Sales.ReopenDeal` — master plan §7.
|
|
9
|
+
*
|
|
10
|
+
* ONE TRANSACTION, ALL-OR-NONE. A partial close — contract created, order missing, lock applied — is
|
|
11
|
+
* the exact failure the transaction exists to prevent (§7.2). Everything below happens inside a single
|
|
12
|
+
* provider transaction, and any throw rolls the whole thing back.
|
|
13
|
+
*
|
|
14
|
+
* ── THE PATH IS RESOLVED FROM FLAGS, NEVER NAMES ────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* The operation is called `CloseDeal`, not `WinDeal`, on purpose. It reads `IsWon` / `IsLost` /
|
|
17
|
+
* `LocksDeal` off the target `DealStatusType` row and branches on those. Nothing here compares a
|
|
18
|
+
* status name, a pipeline name, or the strings "B2B" / "D2C" — a deployment renames those freely and a
|
|
19
|
+
* rename must not change what a close does. Routing comes from `Pipeline.CloseWonPolicy`, which is
|
|
20
|
+
* config-as-data, and from nowhere else. The CI vocabulary grep enforces this.
|
|
21
|
+
*
|
|
22
|
+
* ── SALES STILL COMPUTES NO MONEY ───────────────────────────────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The close routes lines and passes intent. It does not price them, total them, or apply a discount.
|
|
25
|
+
* Every figure comes back from orders, and while the downstream is stubbed no figure exists at all —
|
|
26
|
+
* which is honest rather than convenient.
|
|
27
|
+
*
|
|
28
|
+
* @module @mj-biz-apps/sales-core-entities-server
|
|
29
|
+
*/
|
|
30
|
+
import { BaseRemotableOperation, LogError, } from '@memberjunction/core';
|
|
31
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
32
|
+
/**
|
|
33
|
+
* ORDERS' OWN PREDICATE, imported rather than restated -- the same reason
|
|
34
|
+
* `DealEntityServer` imports `CanTransition` from this module.
|
|
35
|
+
*
|
|
36
|
+
* `IsBooked` is `Confirmed | Posted | Fulfilled`, and orders documents it as "journal entries
|
|
37
|
+
* exist and the receivable is real". Writing `status === 'Confirmed'` here instead would be a
|
|
38
|
+
* vocabulary string-comparison against ANOTHER APP'S words -- exactly what the vocabulary gate
|
|
39
|
+
* exists to catch -- and it would also be wrong twice over. See the refusal below.
|
|
40
|
+
*/
|
|
41
|
+
import { IsBooked } from '@mj-biz-apps/orders-entities';
|
|
42
|
+
import { SalesCloseDealOperation as SalesCloseDealOperationBase, SalesReopenDealOperation as SalesReopenDealOperationBase, StubDownstreamSeam, } from '@mj-biz-apps/sales-entities';
|
|
43
|
+
import { ContractsIsInstalled, LiveContractsSeam } from './LiveContractsSeam.js';
|
|
44
|
+
import { CloseWonTaskDueAt, CloseWonTaskService, ReadCloseWonTaskConfig } from './CloseWonTaskService.js';
|
|
45
|
+
const DEAL_ENTITY = 'MJ_BizApps_Sales: Deals';
|
|
46
|
+
const DEAL_STATUS_ENTITY = 'MJ_BizApps_Sales: Deal Status Types';
|
|
47
|
+
const DEAL_TYPE_ENTITY = 'MJ_BizApps_Sales: Deal Types';
|
|
48
|
+
const LOSS_REASON_ENTITY = 'MJ_BizApps_Sales: Loss Reasons';
|
|
49
|
+
const PIPELINE_ENTITY = 'MJ_BizApps_Sales: Pipelines';
|
|
50
|
+
// Read to resolve a closing stage by the FLAG its status carries -- see closingStageForOutcome.
|
|
51
|
+
const STAGE_ENTITY = 'MJ_BizApps_Sales: Pipeline Stages';
|
|
52
|
+
/**
|
|
53
|
+
* READ, never written. This operation stopped writing to the stage log — it declares the transition and
|
|
54
|
+
* `DealEntityServer` appends the one row it owes (see `DeclareTransition`). It reads the log because the
|
|
55
|
+
* log is where the answer to "what stage was this deal in before it closed" already lives.
|
|
56
|
+
*/
|
|
57
|
+
const STAGE_EVENT_ENTITY = 'MJ_BizApps_Sales: Deal Stage Events';
|
|
58
|
+
/** Defaults applied when a pipeline's policy is silent on a key. */
|
|
59
|
+
const POLICY_DEFAULTS = {
|
|
60
|
+
CreateContract: false,
|
|
61
|
+
SubscriptionLinesTo: 'None',
|
|
62
|
+
// `OrderState` IS GONE FROM THE POLICY ENTIRELY, and not because it was merely unread.
|
|
63
|
+
//
|
|
64
|
+
// A close-time key could only ever speak at one moment, and only about a won close. The stage is
|
|
65
|
+
// where the answer belongs: `PipelineStage.OrderStatusOnEntry` says what entering ANY stage means
|
|
66
|
+
// for the order -- won, lost, or halfway -- and it is one source of truth rather than a policy key
|
|
67
|
+
// that agreed with the stage table by luck. Ruled by Andrew, `docs/DECISIONS.md` D-OS1. Removed
|
|
68
|
+
// from the published input contract in the same commit, because two spellings of the same idea is
|
|
69
|
+
// the problem being solved.
|
|
70
|
+
//
|
|
71
|
+
// `OneTimeLinesTo` is still DEAD but still here: it steered one-time lines to the Order route,
|
|
72
|
+
// which no longer exists. Narrowing the published contract is its own decision and Andrew has not
|
|
73
|
+
// made it -- see `DECISIONS-NEEDED.md` DN-11. A deployment setting it is configuring nothing.
|
|
74
|
+
OneTimeLinesTo: 'None',
|
|
75
|
+
};
|
|
76
|
+
function issue(Section, Message, Field = null) {
|
|
77
|
+
return { Section, Field, Severity: 'error', Message };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The deal's order-status warnings, as Issues — the channel D-OS1's rule 3 needs.
|
|
81
|
+
*
|
|
82
|
+
* A stage change that asks the order for a status orders refuses must not fail the operation, so the
|
|
83
|
+
* refusal cannot be an error. It also must not be silent, or the one case S-US8 GUARANTEES will happen —
|
|
84
|
+
* a reopened lost deal whose order is Voided — reads as a clean reopen that quietly did half the job.
|
|
85
|
+
*
|
|
86
|
+
* `Severity: 'warning'` is already in the published union, so this needs no contract change.
|
|
87
|
+
*/
|
|
88
|
+
function orderStatusIssues(deal) {
|
|
89
|
+
return deal.OrderStatusWarnings.map((Message) => ({
|
|
90
|
+
Section: 'deal',
|
|
91
|
+
Field: 'PipelineStageID',
|
|
92
|
+
Severity: 'warning',
|
|
93
|
+
Message,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A planned downstream route that did NOT execute, as a warning Issue.
|
|
98
|
+
*
|
|
99
|
+
* ── WHY THIS EXISTS WHEN `Routing` ALREADY CARRIES THE REASON ───────────────────────────────────
|
|
100
|
+
*
|
|
101
|
+
* It carries it for anyone who reads `Routing`. The workspace does — `deal-workspace.component.html`
|
|
102
|
+
* renders `— not created: {{ r.Reason }}` on a `--failed` row — so a REP closing a deal already sees
|
|
103
|
+
* the refusal and why. That surface is fine and is not what this is for.
|
|
104
|
+
*
|
|
105
|
+
* The gap is programmatic. An API client, an agent or an importer that checks `Success` and reads
|
|
106
|
+
* `Issues` — the two fields every other failure mode in this operation reports through — sees
|
|
107
|
+
* `Success: true` and an EMPTY `Issues` array on a close whose contract was refused. Measured on this
|
|
108
|
+
* host: `DEAL-9003` is a renewal with no `RenewsContractID`, the seam declined with a clear reason, and
|
|
109
|
+
* the envelope reported unqualified success. Nothing was wrong with the close; everything was wrong
|
|
110
|
+
* with how a non-browser caller would have read it.
|
|
111
|
+
*
|
|
112
|
+
* ── SEVERITY IS `warning`, AND `Success` IS DELIBERATELY UNTOUCHED ──────────────────────────────
|
|
113
|
+
*
|
|
114
|
+
* A refused downstream route must not fail a won deal. The deal IS won: the status is written, the
|
|
115
|
+
* stage event is stamped, the order is intact. Rolling that back because a contract could not be
|
|
116
|
+
* derived would trade a recorded win for an unrecorded one, and the refusals are the expected cases —
|
|
117
|
+
* a renewal with no parent contract, a subscription route waiting on orders' C0.
|
|
118
|
+
*
|
|
119
|
+
* So this is the same shape as {@link orderStatusIssues} one function above, for the same reason: the
|
|
120
|
+
* thing must be loud without being fatal. `close-deal.CD23` pins BOTH halves, because the regression to
|
|
121
|
+
* fear is not the warning disappearing — it is somebody reading a warning as a bug and "fixing" it by
|
|
122
|
+
* failing the close.
|
|
123
|
+
*/
|
|
124
|
+
function routingIssues(routing,
|
|
125
|
+
/**
|
|
126
|
+
* PREVIEW CHANGES WHAT "NOT EXECUTED" MEANS, and getting this wrong told every tester their
|
|
127
|
+
* first close had failed.
|
|
128
|
+
*
|
|
129
|
+
* `execute()` does not run until AFTER the preview returns, so in preview `Executed` is false on
|
|
130
|
+
* every route by construction. Filtering on `Planned && !Executed` therefore reported
|
|
131
|
+
* "Contract was planned but not created: no reason given" on EVERY B2B preview — a failure
|
|
132
|
+
* message for something nobody had attempted yet.
|
|
133
|
+
*
|
|
134
|
+
* Suppressing route reporting in preview would be the wrong correction: a preview exists to say
|
|
135
|
+
* what it plans. But that is what `Routing` is for, and it IS returned in preview, in full.
|
|
136
|
+
* `Issues` is the channel for what is WRONG, and its severity union is `'error' | 'warning'` —
|
|
137
|
+
* there is no informational level to demote a plan into, so a plan does not belong here at all.
|
|
138
|
+
*
|
|
139
|
+
* WHAT STILL WARRANTS A WARNING IN PREVIEW is a route that already knows it cannot run, and the
|
|
140
|
+
* distinction was already in the data. A route carrying a `Reason` AT PLAN TIME has a known
|
|
141
|
+
* blocker — the Subscription route names orders' missing `Subscription.BillingMode` — while a
|
|
142
|
+
* route with no reason is simply one nothing has tried yet. Preview reports the first and stays
|
|
143
|
+
* quiet about the second. That is also what makes a real refusal distinguishable from the
|
|
144
|
+
* ordinary case, which it was not before: every route read the same.
|
|
145
|
+
*/
|
|
146
|
+
preview) {
|
|
147
|
+
return routing
|
|
148
|
+
.filter((r) => r.Planned === true && r.Executed !== true)
|
|
149
|
+
.filter((r) => !preview || r.Reason != null)
|
|
150
|
+
.map((r) => ({
|
|
151
|
+
Section: 'deal',
|
|
152
|
+
Field: null,
|
|
153
|
+
Severity: 'warning',
|
|
154
|
+
// Worded to match what the workspace shows, so a rep reading the screen and an agent
|
|
155
|
+
// reading the envelope are quoting the same sentence back at each other.
|
|
156
|
+
Message: preview
|
|
157
|
+
? `${r.Target} is planned, but will not be created: ${r.Reason}`
|
|
158
|
+
: `${r.Target} was planned but not created: ${r.Reason ?? 'no reason given'}`,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The seam the close flow calls downstream.
|
|
163
|
+
*
|
|
164
|
+
* A module-level default rather than a constructor argument because remote operations are built by the
|
|
165
|
+
* ClassFactory with no arguments. {@link SetDownstreamSeam} is how a test — or a future real
|
|
166
|
+
* implementation — replaces it.
|
|
167
|
+
*/
|
|
168
|
+
let downstreamSeam = new StubDownstreamSeam();
|
|
169
|
+
/** Set once a caller overrides the seam explicitly, so auto-selection stops second-guessing them. */
|
|
170
|
+
let seamWasSetExplicitly = false;
|
|
171
|
+
/** Swap the downstream implementation. Returns the previous one so a test can restore it. */
|
|
172
|
+
export function SetDownstreamSeam(seam) {
|
|
173
|
+
const previous = downstreamSeam;
|
|
174
|
+
downstreamSeam = seam;
|
|
175
|
+
seamWasSetExplicitly = true;
|
|
176
|
+
return previous;
|
|
177
|
+
}
|
|
178
|
+
export function GetDownstreamSeam() {
|
|
179
|
+
return downstreamSeam;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Undo {@link SetDownstreamSeam} and return to DEPLOYMENT-BASED selection.
|
|
183
|
+
*
|
|
184
|
+
* Needed because `SetDownstreamSeam` latches: it records that a caller took control, so auto-selection
|
|
185
|
+
* stops second-guessing them. A test that installs a fake and then merely sets the old value back would
|
|
186
|
+
* leave that latch on, and every later check in the process would keep using whatever it restored —
|
|
187
|
+
* silently, and only on hosts where the live seam would otherwise have been chosen.
|
|
188
|
+
*/
|
|
189
|
+
export function ResetDownstreamSeam() {
|
|
190
|
+
downstreamSeam = new StubDownstreamSeam();
|
|
191
|
+
seamWasSetExplicitly = false;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Choose the seam for THIS execution.
|
|
195
|
+
*
|
|
196
|
+
* Deployment decides, not configuration: if orders' entities are registered, the live handoff is used;
|
|
197
|
+
* if they are not — sales installed standalone, which is supported — the stub records the intent and
|
|
198
|
+
* reports `Executed: false`. An explicit {@link SetDownstreamSeam} always wins, because a test that
|
|
199
|
+
* installed a fake must not have it silently replaced by the real thing.
|
|
200
|
+
*
|
|
201
|
+
* Built per call rather than cached because it closes over the acting user, and a seam that reused the
|
|
202
|
+
* first caller's identity would write orders' records as the wrong person.
|
|
203
|
+
*/
|
|
204
|
+
function resolveSeam(user, provider) {
|
|
205
|
+
if (seamWasSetExplicitly) {
|
|
206
|
+
return downstreamSeam;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* ── THERE IS ONLY ONE LIVE DOWNSTREAM LEFT, SO THERE IS ONLY ONE THING TO RESOLVE ───────────────
|
|
210
|
+
*
|
|
211
|
+
* This used to compose two halves: an orders seam wrapping a contracts seam, plus a stub-orders
|
|
212
|
+
* variant for a contracts-without-orders host. All of that existed because close-won CREATED the
|
|
213
|
+
* order a won deal earned. It does not any more — the order is embedded on the deal from creation —
|
|
214
|
+
* so `LiveOrdersSeam` lost its two real methods and ended as a 128-line pass-through that forwarded
|
|
215
|
+
* the contract calls to the seam it had been handed. Wrapping a seam in a class that only forwards to
|
|
216
|
+
* it is not composition; it is a layer to read past. Both it and `StubOrdersWithContracts` are gone.
|
|
217
|
+
*
|
|
218
|
+
* The independence the old comment defended is preserved and is now simply true by construction:
|
|
219
|
+
* contracts resolves on its own flag and orders is not consulted here at all. A host with contracts
|
|
220
|
+
* and no orders gets the live contracts seam, which is exactly what `StubOrdersWithContracts` was
|
|
221
|
+
* built to arrange.
|
|
222
|
+
*
|
|
223
|
+
* `OrdersIsInstalled()` still matters to the close — it decides whether an orders-dependent route is
|
|
224
|
+
* available — and now lives in `orders-availability.js`, because it answers a question about the HOST
|
|
225
|
+
* rather than about a seam.
|
|
226
|
+
*/
|
|
227
|
+
return ContractsIsInstalled() ? new LiveContractsSeam(user, provider) : new StubDownstreamSeam();
|
|
228
|
+
}
|
|
229
|
+
let CloseDealOperation = class CloseDealOperation extends SalesCloseDealOperationBase {
|
|
230
|
+
async InternalExecute(input, provider, user) {
|
|
231
|
+
const empty = {
|
|
232
|
+
Success: false,
|
|
233
|
+
Issues: [],
|
|
234
|
+
IsWon: false,
|
|
235
|
+
IsLost: false,
|
|
236
|
+
Locked: false,
|
|
237
|
+
Routing: [],
|
|
238
|
+
WasPreview: input?.PreviewOnly === true,
|
|
239
|
+
};
|
|
240
|
+
if (!input?.DealID) {
|
|
241
|
+
return { ...empty, Issues: [issue('deal', 'A deal is required.', 'DealID')] };
|
|
242
|
+
}
|
|
243
|
+
if (!input.DealStatusTypeID) {
|
|
244
|
+
return { ...empty, Issues: [issue('deal', 'A target status is required.', 'DealStatusTypeID')] };
|
|
245
|
+
}
|
|
246
|
+
const db = provider;
|
|
247
|
+
let transactionOpen = false;
|
|
248
|
+
try {
|
|
249
|
+
// ── 1. Load the deal and resolve the target status FLAGS ──────────────
|
|
250
|
+
const deal = await provider.GetEntityObject(DEAL_ENTITY, user);
|
|
251
|
+
if (!(await deal.Load(input.DealID))) {
|
|
252
|
+
return { ...empty, Issues: [issue('deal', `Deal ${input.DealID} was not found.`, 'DealID')] };
|
|
253
|
+
}
|
|
254
|
+
const target = await this.loadStatusFlags(input.DealStatusTypeID, provider, user);
|
|
255
|
+
if (!target) {
|
|
256
|
+
return {
|
|
257
|
+
...empty,
|
|
258
|
+
Issues: [issue('deal', `Status ${input.DealStatusTypeID} was not found.`, 'DealStatusTypeID')],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// Already locked? Refuse before doing anything — reopening is the sanctioned path.
|
|
262
|
+
const current = deal.DealStatusTypeID
|
|
263
|
+
? await this.loadStatusFlags(deal.DealStatusTypeID, provider, user)
|
|
264
|
+
: null;
|
|
265
|
+
if (current?.LocksDeal) {
|
|
266
|
+
return {
|
|
267
|
+
...empty,
|
|
268
|
+
Issues: [
|
|
269
|
+
issue('deal', 'This deal is already closed and locked. Reopen it through Sales.ReopenDeal first.'),
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// ── 2. Validate what this particular close requires ───────────────────
|
|
274
|
+
const validation = await this.validate(deal, input, target, provider, user);
|
|
275
|
+
if (validation.length) {
|
|
276
|
+
return { ...empty, IsWon: target.IsWon, IsLost: target.IsLost, Issues: validation };
|
|
277
|
+
}
|
|
278
|
+
// ── 3. Resolve the effective policy, and route ────────────────────────
|
|
279
|
+
// A LOST deal routes nowhere: §7.2 step 2 writes the stage event, locks, and is done.
|
|
280
|
+
const policy = target.IsWon
|
|
281
|
+
? await this.resolvePolicy(deal, input, provider, user)
|
|
282
|
+
: { ...POLICY_DEFAULTS };
|
|
283
|
+
const routing = target.IsWon
|
|
284
|
+
? await this.planRouting(deal, policy, provider, user)
|
|
285
|
+
: [];
|
|
286
|
+
/**
|
|
287
|
+
* THE UNROUTABLE-LINE REFUSAL (KI-14) WAS HERE, and it is gone with the table it guarded.
|
|
288
|
+
*
|
|
289
|
+
* It refused a close when a line carried a product NAME with no `ProductID`, because both
|
|
290
|
+
* builders coerced that to `ProductID: ''` and an empty string against a `uniqueidentifier`
|
|
291
|
+
* failed inside the sibling's save — after the status had been written. Deal won, nothing
|
|
292
|
+
* downstream.
|
|
293
|
+
*
|
|
294
|
+
* That shape cannot be constructed any more. `OrderLine.ProductID` is `NOT NULL` with a real
|
|
295
|
+
* FK to the catalogue, so a line without a product does not exist to be refused. Recorded as
|
|
296
|
+
* deliberate in docs/DECISIONS.md D-DL1 rather than left to be noticed missing.
|
|
297
|
+
*
|
|
298
|
+
* The HubSpot half of KI-14 survives as an IMPORT concern: a deal imported with a product
|
|
299
|
+
* name and no catalogue ID now fails when the importer tries to write the order line, which
|
|
300
|
+
* is the right place for it to fail.
|
|
301
|
+
*/
|
|
302
|
+
if (input.PreviewOnly) {
|
|
303
|
+
// Nothing written, nothing locked — the caller just wanted to see the consequences.
|
|
304
|
+
// A refusal IS one of those consequences, so the preview reports it too: a caller that
|
|
305
|
+
// previews before closing should not learn about it only after committing.
|
|
306
|
+
return {
|
|
307
|
+
Success: true,
|
|
308
|
+
Issues: routingIssues(routing, true),
|
|
309
|
+
IsWon: target.IsWon,
|
|
310
|
+
IsLost: target.IsLost,
|
|
311
|
+
Locked: false,
|
|
312
|
+
EffectivePolicy: policy,
|
|
313
|
+
Routing: routing,
|
|
314
|
+
WasPreview: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
// ── 4. Execute: one transaction, all-or-none ──────────────────────────
|
|
318
|
+
await db.BeginTransaction();
|
|
319
|
+
transactionOpen = true;
|
|
320
|
+
for (const plan of routing) {
|
|
321
|
+
await this.execute(plan, deal, policy, provider, user);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* THE FINANCE TASKS A WON DEAL OWES (S-US2 #34, S-US3 #35).
|
|
325
|
+
*
|
|
326
|
+
* INSIDE the transaction, deliberately and on evidence. `TaskOrchestrationService` writes
|
|
327
|
+
* through the global `Metadata.Provider`, which in a server process is the SAME provider this
|
|
328
|
+
* operation opened its transaction on — so the writes join it and a rolled-back close takes
|
|
329
|
+
* the tasks with it. Measured rather than assumed: a probe that created a task inside an
|
|
330
|
+
* ambient transaction and rolled back found the row gone, and the integration suite has
|
|
331
|
+
* created hundreds of task rows inside rolled-back checks without leaving one behind.
|
|
332
|
+
*
|
|
333
|
+
* Its issues are folded in as WARNINGS, not errors. The deal really did close and its
|
|
334
|
+
* provenance is written; an unconfigured task type or a missing finance assignee is a
|
|
335
|
+
* deployment gap to surface, not a reason to refuse a close that has already succeeded.
|
|
336
|
+
* `Section: 'deal'` because the issue vocabulary has no tasks section — worth adding when
|
|
337
|
+
* the surface exists to badge it.
|
|
338
|
+
*/
|
|
339
|
+
const taskIssues = [];
|
|
340
|
+
/**
|
|
341
|
+
* The stage this close lands in, resolved BEFORE the stamp because `stampClose` is
|
|
342
|
+
* synchronous and this needs two reads. `input.ClosingStageID` wins when supplied.
|
|
343
|
+
*/
|
|
344
|
+
const derivedClosingStageID = input.ClosingStageID
|
|
345
|
+
? null
|
|
346
|
+
: await this.closingStageForOutcome(deal.PipelineID, target, provider, user);
|
|
347
|
+
this.stampClose(deal, input, target, routing, user, derivedClosingStageID);
|
|
348
|
+
if (!(await deal.Save())) {
|
|
349
|
+
throw new Error(`the deal could not be saved: ${deal.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
350
|
+
}
|
|
351
|
+
// The save appended the close's `DealStageEvent` — one row, whether or not the stage moved.
|
|
352
|
+
// Read AFTER it for the same reason `OrderID` is: the row does not exist until then.
|
|
353
|
+
const stageEvent = deal.LastStageEventID;
|
|
354
|
+
/**
|
|
355
|
+
* ── THE TASKS RUN **AFTER** THE SAVE, AND THAT IS THE FIX RATHER THAN A TIDY-UP ──────────
|
|
356
|
+
*
|
|
357
|
+
* This block used to sit twenty lines earlier, before `deal.Save()`, and it read
|
|
358
|
+
* `deal.OrderID`. That was correct until provisioning MOVED INTO `Save()` this round. After
|
|
359
|
+
* that move, closing any deal that did not already carry an order — every seeded, legacy and
|
|
360
|
+
* imported deal — read an EMPTY OrderID, so the service reported
|
|
361
|
+
*
|
|
362
|
+
* "The deal has no order, so no order-review task was created…"
|
|
363
|
+
*
|
|
364
|
+
* as a warning on a SUCCESSFUL close, raised no order-review task, and then `Save()` created
|
|
365
|
+
* the order a moment later. Finance got nothing, and the warning stated the opposite of what
|
|
366
|
+
* had just happened. Two rules that were each right on their own, in the wrong order.
|
|
367
|
+
*
|
|
368
|
+
* Reading after the save is the smaller of the two available fixes: provisioning explicitly
|
|
369
|
+
* beforehand would put a second caller in the business of knowing when an order is due, which
|
|
370
|
+
* is exactly what moving it into `Save()` was for.
|
|
371
|
+
*
|
|
372
|
+
* STILL INSIDE THE TRANSACTION. `CommitTransaction` is below, so the tasks remain
|
|
373
|
+
* all-or-none with the close — `close-won-tasks.WT9` is the check that pins that.
|
|
374
|
+
*
|
|
375
|
+
* ── AND `ContractID` IS PASSED, WHICH IT NEVER WAS ──────────────────────────────────────
|
|
376
|
+
*
|
|
377
|
+
* `executeRoute` sets `deal.ContractID` in the routing loop that runs before this, so the
|
|
378
|
+
* value has been available all along and the input literal simply omitted it. The service's
|
|
379
|
+
* `if (input.ContractID)` branch was therefore UNREACHABLE from production, and its
|
|
380
|
+
* "contracts is not installed" fallback was dead code. Every contract-processing task linked
|
|
381
|
+
* the DEAL instead of the contract it exists to process, and nothing reported it, because the
|
|
382
|
+
* branch that would have reported it is the one that never ran.
|
|
383
|
+
*/
|
|
384
|
+
if (target.IsWon) {
|
|
385
|
+
const cfg = ReadCloseWonTaskConfig(policy);
|
|
386
|
+
const taskResult = await new CloseWonTaskService().CreateCloseWonTasks({
|
|
387
|
+
DealID: deal.ID,
|
|
388
|
+
/**
|
|
389
|
+
* The NAME, so the finance queue reads as deals rather than as hex. The service
|
|
390
|
+
* used to interpolate the id into the task name; the id is still reachable, because
|
|
391
|
+
* both tasks now carry a link to the deal itself.
|
|
392
|
+
*
|
|
393
|
+
* Falls back to the deal number and then to the id: a task named after nothing is
|
|
394
|
+
* worse than one named after a key, and `Name` is nullable on the entity.
|
|
395
|
+
*/
|
|
396
|
+
DealName: deal.Name?.trim() || deal.DealNumber || deal.ID,
|
|
397
|
+
// Read AFTER the save: provisioning happens inside it. See the note above.
|
|
398
|
+
OrderID: String(deal.OrderID ?? ''),
|
|
399
|
+
PipelineID: deal.PipelineID,
|
|
400
|
+
// Set by the routing loop above. Omitting it is what made the contract task link
|
|
401
|
+
// the deal, and made the service's own fallback message unreachable.
|
|
402
|
+
ContractID: deal.ContractID ?? undefined,
|
|
403
|
+
/**
|
|
404
|
+
* ── A DUE DATE, WHICH THESE TASKS HAVE NEVER HAD ────────────────────────
|
|
405
|
+
*
|
|
406
|
+
* `CloseWonTaskService` accepts `DueAt` and passes it straight to tasks'
|
|
407
|
+
* `CreateTask`; this operation simply never supplied one. So every task the app
|
|
408
|
+
* has ever raised carried a NULL due date, and everything downstream that reads
|
|
409
|
+
* one was inert for our tasks: `Task.DueAt`, `IsOverdue`, the Overdue KPI and the
|
|
410
|
+
* `OnOverdue` hook all already exist in tasks and all had nothing to act on.
|
|
411
|
+
*
|
|
412
|
+
* Nothing to build there — only to populate. Dated from the close itself rather
|
|
413
|
+
* than from `new Date()` so the task's clock and the deal's provenance agree, and
|
|
414
|
+
* so a close replayed inside a transaction is deterministic.
|
|
415
|
+
*/
|
|
416
|
+
DueAt: CloseWonTaskDueAt(deal.ClosedAt ?? new Date(), cfg.DueInDays),
|
|
417
|
+
// No task-type IDs: the service resolves both by Code from rows this repo
|
|
418
|
+
// seeds. What the policy still carries is the part that varies — the assignee.
|
|
419
|
+
Assignee: cfg.AssigneeRecordID
|
|
420
|
+
? {
|
|
421
|
+
EntityName: cfg.AssigneeEntityName ?? 'MJ_BizApps_Common: People',
|
|
422
|
+
RecordID: cfg.AssigneeRecordID,
|
|
423
|
+
RoleID: cfg.AssigneeRoleID ?? undefined,
|
|
424
|
+
}
|
|
425
|
+
: undefined,
|
|
426
|
+
}, provider, user);
|
|
427
|
+
for (const message of taskResult.Issues) {
|
|
428
|
+
taskIssues.push({ Section: 'deal', Field: null, Severity: 'warning', Message: message });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
await db.CommitTransaction();
|
|
432
|
+
transactionOpen = false;
|
|
433
|
+
return {
|
|
434
|
+
Success: true,
|
|
435
|
+
/**
|
|
436
|
+
* TWO SOURCES OF NON-FATAL WARNINGS, ONE FIELD, AND A STATED ORDER.
|
|
437
|
+
*
|
|
438
|
+
* Both arrived independently: the close creates finance's tasks (S-US2/S-US3) and can
|
|
439
|
+
* also move the deal into a CLOSING stage whose `OrderStatusOnEntry` names an order
|
|
440
|
+
* status — `Voided` on a lost stage (S-US7). Either can decline without failing the
|
|
441
|
+
* close: a task type that will not resolve, or orders refusing `Voided -> Quoted` on a
|
|
442
|
+
* reopened lost deal, which S-US8 says WILL happen.
|
|
443
|
+
*
|
|
444
|
+
* Each side of the merge assigned this field alone, so taking either textually would
|
|
445
|
+
* have SILENTLY DROPPED the other's warnings — a close that quietly did half its
|
|
446
|
+
* downstream work and reported success. Concatenated deliberately instead, in the order
|
|
447
|
+
* the work actually happened: the tasks are created above, and the order status is
|
|
448
|
+
* written inside `deal.Save()` a few lines further down. So a reader of the array reads
|
|
449
|
+
* the close in sequence, which is the only ordering that means anything here.
|
|
450
|
+
*/
|
|
451
|
+
Issues: [...taskIssues, ...orderStatusIssues(deal), ...routingIssues(routing, false)],
|
|
452
|
+
IsWon: target.IsWon,
|
|
453
|
+
IsLost: target.IsLost,
|
|
454
|
+
Locked: target.LocksDeal,
|
|
455
|
+
EffectivePolicy: policy,
|
|
456
|
+
Routing: routing,
|
|
457
|
+
DealStageEventID: stageEvent,
|
|
458
|
+
ClosedAt: deal.ClosedAt ? String(deal.ClosedAt) : null,
|
|
459
|
+
WasPreview: false,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
if (transactionOpen) {
|
|
464
|
+
try {
|
|
465
|
+
await provider.RollbackTransaction();
|
|
466
|
+
}
|
|
467
|
+
catch (rollbackErr) {
|
|
468
|
+
LogError(`Sales.CloseDeal: rollback failed: ${rollbackErr}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
LogError(`Sales.CloseDeal failed: ${err}`);
|
|
472
|
+
return { ...empty, Issues: [issue('deal', `The deal could not be closed: ${err}`)] };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/* ── Validation (§7.2 step 1–2) ─────────────────────────────────────────── */
|
|
476
|
+
/**
|
|
477
|
+
* What this close requires before it may proceed.
|
|
478
|
+
*
|
|
479
|
+
* The loss-reason rule is the app's ONLY mandatory field and the friction is deliberate: loss
|
|
480
|
+
* reasons are the highest-value and most consistently-skipped data in any CRM (§7.2).
|
|
481
|
+
*/
|
|
482
|
+
async validate(deal, input, target, provider, user) {
|
|
483
|
+
const issues = [];
|
|
484
|
+
if (target.IsLost) {
|
|
485
|
+
const lossReasonID = input.LossReasonID ?? deal.LossReasonID;
|
|
486
|
+
if (!lossReasonID) {
|
|
487
|
+
issues.push(issue('deal', 'A loss reason is required to close a deal as lost.', 'LossReasonID'));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
// Some reasons demand an explanation — the flag says which, never the reason's name.
|
|
491
|
+
const view = provider;
|
|
492
|
+
const r = await view.RunView({
|
|
493
|
+
EntityName: LOSS_REASON_ENTITY,
|
|
494
|
+
ExtraFilter: `ID = '${lossReasonID}'`,
|
|
495
|
+
ResultType: 'simple',
|
|
496
|
+
Fields: ['RequiresNotes'],
|
|
497
|
+
}, user);
|
|
498
|
+
const row = (r.Results ?? [])[0];
|
|
499
|
+
const notes = input.LossNotes ?? deal.LossNotes;
|
|
500
|
+
if (r.Success && row?.RequiresNotes === true && !notes?.trim()) {
|
|
501
|
+
issues.push(issue('deal', 'This loss reason requires notes explaining the loss.', 'LossNotes'));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (target.IsWon && !deal.AccountID) {
|
|
506
|
+
issues.push(issue('party', 'A won deal must be attached to a customer.', 'AccountID'));
|
|
507
|
+
}
|
|
508
|
+
return issues;
|
|
509
|
+
}
|
|
510
|
+
/* ── Policy (§7.1) ──────────────────────────────────────────────────────── */
|
|
511
|
+
/**
|
|
512
|
+
* Pipeline default, with the caller's overrides merged over it (§7.1, D-CF6).
|
|
513
|
+
*
|
|
514
|
+
* A malformed policy is a WARNING-shaped failure that falls back to defaults rather than throwing:
|
|
515
|
+
* a pipeline with unparseable JSON should not make its deals uncloseable, and the effective policy
|
|
516
|
+
* is returned to the caller so the fallback is visible rather than silent.
|
|
517
|
+
*/
|
|
518
|
+
async resolvePolicy(deal, input, provider, user) {
|
|
519
|
+
let fromPipeline = {};
|
|
520
|
+
const view = provider;
|
|
521
|
+
const r = await view.RunView({
|
|
522
|
+
EntityName: PIPELINE_ENTITY,
|
|
523
|
+
ExtraFilter: `ID = '${deal.PipelineID}'`,
|
|
524
|
+
ResultType: 'simple',
|
|
525
|
+
Fields: ['CloseWonPolicy'],
|
|
526
|
+
}, user);
|
|
527
|
+
const raw = (r.Results ?? [])[0]?.CloseWonPolicy;
|
|
528
|
+
if (r.Success && raw) {
|
|
529
|
+
try {
|
|
530
|
+
fromPipeline = JSON.parse(raw);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
LogError(`Sales.CloseDeal: pipeline ${deal.PipelineID} has unparseable CloseWonPolicy; using defaults`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { ...POLICY_DEFAULTS, ...fromPipeline, ...(input.PolicyOverrides ?? {}) };
|
|
537
|
+
}
|
|
538
|
+
/* ── Routing (§7.2 steps 4–5) ───────────────────────────────────────────── */
|
|
539
|
+
/**
|
|
540
|
+
* What the policy says should happen, before anything is attempted.
|
|
541
|
+
*
|
|
542
|
+
* ── THERE IS NO LONGER AN ORDER ROUTE, AND THAT IS THE POINT ──
|
|
543
|
+
*
|
|
544
|
+
* Close-won used to create the order. It no longer does: the order is an EMBEDDED RECORD created
|
|
545
|
+
* with the deal and carrying its lines from the start (S-US4), so by the time a deal closes the
|
|
546
|
+
* order already exists. S-US5 is explicit that Closed Won leaves its status ALONE and editable, so
|
|
547
|
+
* finance can review and correct before the Confirm that locks it and triggers invoicing.
|
|
548
|
+
*
|
|
549
|
+
* So close-won no longer counts lines at all — there are none on the deal to count. What it plans
|
|
550
|
+
* is the CONTRACT (B2B only), and the still-unbuilt Subscription materialization.
|
|
551
|
+
*/
|
|
552
|
+
async planRouting(deal, policy, provider, user) {
|
|
553
|
+
const plans = [];
|
|
554
|
+
if (policy.CreateContract === true) {
|
|
555
|
+
// LineCount 0, and not a placeholder: S-US2's contract is a header — customer, company,
|
|
556
|
+
// contact, type defaults, template, number, the linked pair and TemplateModified. It has no
|
|
557
|
+
// line items, which is the same consolidation that retired DealLine.
|
|
558
|
+
plans.push({ Target: 'Contract', Planned: true, Executed: false, LineCount: 0 });
|
|
559
|
+
}
|
|
560
|
+
if (policy.SubscriptionLinesTo === 'Subscription') {
|
|
561
|
+
plans.push({
|
|
562
|
+
Target: 'Subscription',
|
|
563
|
+
Planned: true,
|
|
564
|
+
Executed: false,
|
|
565
|
+
LineCount: 0,
|
|
566
|
+
Reason: 'Subscription materialization needs orders\' Subscription.BillingMode (C0), which does ' +
|
|
567
|
+
'not exist yet.',
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return plans;
|
|
571
|
+
}
|
|
572
|
+
/** Attempt one planned route through the seam, recording honestly whether it executed. */
|
|
573
|
+
async execute(plan, deal, policy, provider, user) {
|
|
574
|
+
const seam = resolveSeam(user, provider);
|
|
575
|
+
if (plan.Target === 'Contract') {
|
|
576
|
+
// A renewal takes a different door: RenewTerm against the contract being renewed, not a
|
|
577
|
+
// new agreement. Decided by DealType.RequiresRenewalSource — a flag, not a type name.
|
|
578
|
+
const isRenewal = await this.dealTypeRequiresRenewalSource(deal.DealTypeID, provider, user);
|
|
579
|
+
const result = isRenewal
|
|
580
|
+
? await seam.RenewContractTerm({
|
|
581
|
+
ContractID: deal.RenewsContractID ?? '',
|
|
582
|
+
DealID: deal.ID,
|
|
583
|
+
TermMonths: policy.TermMonths ?? deal.TermMonths ?? null,
|
|
584
|
+
})
|
|
585
|
+
: await seam.CreateContractFromDeal(await this.buildContractInput(deal, policy, provider, user));
|
|
586
|
+
plan.Executed = result.Success === true;
|
|
587
|
+
plan.RecordID = result.ContractID ?? null;
|
|
588
|
+
plan.Reason = result.Success ? null : result.Message ?? 'not executed';
|
|
589
|
+
if (result.Success && result.ContractID) {
|
|
590
|
+
// §7.2 step 4: stamp the agreement onto the deal.
|
|
591
|
+
deal.ContractID = result.ContractID;
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
// Subscription materialization has no seam at all yet; the plan already carries its reason.
|
|
596
|
+
plan.Executed = false;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The stage a close should land in, when the caller did not name one.
|
|
600
|
+
*
|
|
601
|
+
* ── THE MIRROR OF `priorStageFromCloseEvent`, AND THE OTHER HALF OF DN-18 ───────────────────────
|
|
602
|
+
*
|
|
603
|
+
* `CD18` proves a reopen restores the stage the deal came from. It could not help a deal closed
|
|
604
|
+
* through the browser, because `DealWorkspaceComponent.ConfirmClose()` sends no `ClosingStageID` — so
|
|
605
|
+
* no browser-driven close ever moved the stage, every close event recorded
|
|
606
|
+
* `FromStageID === ToStageID`, and the reopen correctly derived the stage the deal was already in.
|
|
607
|
+
* The mechanism was right and the UI could not reach it. `71-lost-and-reopen` is the spec that names
|
|
608
|
+
* this.
|
|
609
|
+
*
|
|
610
|
+
* Fixed in the OPERATION rather than in `ConfirmClose`, for the fourth time and the same reason: an
|
|
611
|
+
* agent, an importer and an API caller all close deals too, and a rule the app owns belongs on the
|
|
612
|
+
* write path. `input.ClosingStageID` stays an override.
|
|
613
|
+
*
|
|
614
|
+
* ── CHOSEN BY FLAG, WHICH IS THE ONLY WAY THIS CAN WORK ────────────────────────────────────────
|
|
615
|
+
*
|
|
616
|
+
* The stage on the deal's OWN pipeline whose declared `DealStatusType` matches the outcome being
|
|
617
|
+
* recorded — `IsWon` for a won close, `IsLost` for a lost one. Never a name: a pipeline is free to
|
|
618
|
+
* call its winning stage "Signed", "Booked" or anything else, and §3 is what makes that a label.
|
|
619
|
+
*
|
|
620
|
+
* Returns null rather than guessing when the pipeline declares no such stage. A deal on a pipeline
|
|
621
|
+
* with no losing stage closes as lost without moving, exactly as it did before this existed — the
|
|
622
|
+
* close is never blocked by the absence of somewhere to put it.
|
|
623
|
+
*
|
|
624
|
+
* ── AND IT LEANS ON TWO GUARDS THAT ARE NOW LOAD-BEARING ───────────────────────────────────────
|
|
625
|
+
*
|
|
626
|
+
* Moving into a stage that declares a LOCKING status is only safe because `applyStageDefaults` refuses
|
|
627
|
+
* to derive a locking status (`SD35`), so the stage cannot close the deal a second time; and the
|
|
628
|
+
* closing status stays authoritative because a declared transition suppresses stage defaults entirely
|
|
629
|
+
* (`CD17`). Both are mutant-proven. Without them this change would double-close every deal it touched.
|
|
630
|
+
*/
|
|
631
|
+
async closingStageForOutcome(pipelineID, target, provider, user) {
|
|
632
|
+
if (!pipelineID || (!target.IsWon && !target.IsLost)) {
|
|
633
|
+
return null; // an outcome that is neither won nor lost names no stage
|
|
634
|
+
}
|
|
635
|
+
const view = provider;
|
|
636
|
+
const statuses = await view.RunView({
|
|
637
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
638
|
+
ExtraFilter: target.IsWon ? 'IsWon = 1' : 'IsLost = 1',
|
|
639
|
+
ResultType: 'simple',
|
|
640
|
+
Fields: ['ID'],
|
|
641
|
+
}, user);
|
|
642
|
+
if (!statuses.Success) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
const ids = (statuses.Results ?? []).map((r) => String(r.ID)).filter(Boolean);
|
|
646
|
+
if (ids.length === 0) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const quoted = ids.map((id) => `'${id}'`).join(', ');
|
|
650
|
+
const stages = await view.RunView({
|
|
651
|
+
EntityName: STAGE_ENTITY,
|
|
652
|
+
ExtraFilter: `PipelineID = '${pipelineID}' AND IsActive = 1 AND DealStatusTypeID IN (${quoted})`,
|
|
653
|
+
OrderBy: 'DisplayOrder ASC',
|
|
654
|
+
ResultType: 'simple',
|
|
655
|
+
Fields: ['ID'],
|
|
656
|
+
}, user);
|
|
657
|
+
if (!stages.Success) {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
const row = (stages.Results ?? [])[0];
|
|
661
|
+
return row?.ID ? String(row.ID) : null;
|
|
662
|
+
}
|
|
663
|
+
/* ── The close stamp (§7.2 step 6) ──────────────────────────────────────── */
|
|
664
|
+
/**
|
|
665
|
+
* Append the provenance row and stamp the deal.
|
|
666
|
+
*
|
|
667
|
+
* `DealStageEvent` is APPEND-ONLY and stamps `AmountAtTransition` / `ProbabilityAtTransition`,
|
|
668
|
+
* because "what did we think this was worth when we closed it" is unanswerable later once amounts
|
|
669
|
+
* change. The routing outcome goes into `Notes` — which is what preserves the intent of a close
|
|
670
|
+
* whose downstream was stubbed (D-CF1).
|
|
671
|
+
*
|
|
672
|
+
* §7 also calls for writing an `Activity` here. The Activity spine does not exist in common or MJ
|
|
673
|
+
* core, so that step is skipped and recorded in CLOSE-FLOW-DECISIONS.md D-CF5 rather than faked.
|
|
674
|
+
*/
|
|
675
|
+
stampClose(deal, input, target, routing, user, derivedClosingStageID) {
|
|
676
|
+
/**
|
|
677
|
+
* ── DECLARED, NOT WRITTEN. THIS METHOD USED TO APPEND THE ROW ITSELF ────────────────────────
|
|
678
|
+
*
|
|
679
|
+
* It built a `DealStageEvent` here and then set `deal.PipelineStageID` below — so `Save()` saw a
|
|
680
|
+
* stage change and appended a SECOND row for the same close. Every close that passed a
|
|
681
|
+
* `ClosingStageID` doubled its own provenance, and `CD4` was blind to it because it never passes
|
|
682
|
+
* one.
|
|
683
|
+
*
|
|
684
|
+
* `DeclareTransition` hands the entity the one thing it could not derive — the routing note — and
|
|
685
|
+
* leaves the writing to the single writer, which also suppresses the stage defaults so a close's
|
|
686
|
+
* probability is not re-derived from the stage it lands in. The `From`/`To` stamps come out the
|
|
687
|
+
* same, because the entity reads them from `OldValue`, which is what this method read too.
|
|
688
|
+
*
|
|
689
|
+
* The event id is no longer available here: it exists only once the save has appended the row.
|
|
690
|
+
* The caller reads `deal.LastStageEventID` afterwards.
|
|
691
|
+
*/
|
|
692
|
+
deal.DeclareTransition('Close', this.routingNote(routing, input.Notes));
|
|
693
|
+
// Everything stored is UTC — getUTC*, never local-time getters, for anything persisted.
|
|
694
|
+
const now = new Date();
|
|
695
|
+
deal.DealStatusTypeID = target.ID;
|
|
696
|
+
// Derived when the caller named no stage -- see closingStageForOutcome. This is what gives a
|
|
697
|
+
// browser-driven close a stage to move into, and therefore gives the reopen something to restore.
|
|
698
|
+
const closingStage = input.ClosingStageID ?? derivedClosingStageID;
|
|
699
|
+
if (closingStage) {
|
|
700
|
+
deal.PipelineStageID = closingStage;
|
|
701
|
+
}
|
|
702
|
+
if (input.LossReasonID) {
|
|
703
|
+
deal.LossReasonID = input.LossReasonID;
|
|
704
|
+
}
|
|
705
|
+
if (input.LossNotes) {
|
|
706
|
+
deal.LossNotes = input.LossNotes;
|
|
707
|
+
}
|
|
708
|
+
deal.ClosedAt = now;
|
|
709
|
+
deal.ClosedByUserID = user.ID;
|
|
710
|
+
// ActualCloseDate is a DATE, not a datetime: truncate at UTC midnight so a deal closed at
|
|
711
|
+
// 23:00 UTC does not land in tomorrow's period for a reader in another timezone.
|
|
712
|
+
deal.ActualCloseDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
713
|
+
}
|
|
714
|
+
/** A human-readable record of what the policy routed, and what actually happened. */
|
|
715
|
+
routingNote(routing, callerNote) {
|
|
716
|
+
const parts = [];
|
|
717
|
+
if (callerNote?.trim()) {
|
|
718
|
+
parts.push(callerNote.trim());
|
|
719
|
+
}
|
|
720
|
+
if (routing.length === 0) {
|
|
721
|
+
parts.push('Closed. Policy routed nothing downstream.');
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
for (const r of routing) {
|
|
725
|
+
parts.push(r.Executed
|
|
726
|
+
? `${r.Target}: created ${r.RecordID ?? '(no id)'} (${r.LineCount} line(s)).`
|
|
727
|
+
: `${r.Target}: PLANNED but not executed (${r.LineCount} line(s)) — ${r.Reason ?? 'no reason given'}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return parts.join(' ');
|
|
731
|
+
}
|
|
732
|
+
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
733
|
+
async loadStatusFlags(statusID, provider, user) {
|
|
734
|
+
const view = provider;
|
|
735
|
+
const r = await view.RunView({
|
|
736
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
737
|
+
ExtraFilter: `ID = '${statusID}'`,
|
|
738
|
+
ResultType: 'simple',
|
|
739
|
+
Fields: ['ID', 'IsWon', 'IsLost', 'IsClosed', 'LocksDeal'],
|
|
740
|
+
}, user);
|
|
741
|
+
if (!r.Success) {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
const row = (r.Results ?? [])[0];
|
|
745
|
+
if (!row) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
ID: String(row.ID),
|
|
750
|
+
IsWon: row.IsWon === true,
|
|
751
|
+
IsLost: row.IsLost === true,
|
|
752
|
+
IsClosed: row.IsClosed === true,
|
|
753
|
+
LocksDeal: row.LocksDeal === true,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/** Split by the type's `IsRecurring` FLAG. A join, so one read rather than one per line. */
|
|
757
|
+
async dealTypeRequiresRenewalSource(dealTypeID, provider, user) {
|
|
758
|
+
if (!dealTypeID) {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
const view = provider;
|
|
762
|
+
const r = await view.RunView({
|
|
763
|
+
EntityName: DEAL_TYPE_ENTITY,
|
|
764
|
+
ExtraFilter: `ID = '${dealTypeID}'`,
|
|
765
|
+
ResultType: 'simple',
|
|
766
|
+
Fields: ['RequiresRenewalSource'],
|
|
767
|
+
}, user);
|
|
768
|
+
const row = (r.Results ?? [])[0];
|
|
769
|
+
return r.Success && row?.RequiresRenewalSource === true;
|
|
770
|
+
}
|
|
771
|
+
async buildContractInput(deal, policy, provider, user) {
|
|
772
|
+
return {
|
|
773
|
+
DealID: deal.ID,
|
|
774
|
+
CompanyID: deal.CompanyID,
|
|
775
|
+
ContractTypeCode: policy.ContractTypeCode ?? null,
|
|
776
|
+
TermMonths: policy.TermMonths ?? deal.TermMonths ?? null,
|
|
777
|
+
// The AD's red-lines travel with the agreement — a contract that silently dropped them
|
|
778
|
+
// would send a human reviewer in blind.
|
|
779
|
+
ContractVariances: deal.ContractVariances,
|
|
780
|
+
// And the flag that says whether there ARE any, which is the thing finance's review keys
|
|
781
|
+
// on. Reported even when the variances field is blank: the two answer different questions.
|
|
782
|
+
StandardAgreementModified: deal.StandardAgreementModified,
|
|
783
|
+
AutoRenew: deal.AutoRenew,
|
|
784
|
+
AnnualIncreasePctOverride: deal.AnnualIncreasePctOverride,
|
|
785
|
+
CancellationNoticeDaysOverride: deal.CancellationNoticeDaysOverride,
|
|
786
|
+
/**
|
|
787
|
+
* STATED, BUT NOT STAMPED ONTO THE CONTRACT — and the split is deliberate.
|
|
788
|
+
*
|
|
789
|
+
* These are facts about the DEAL, so this method reports them faithfully. What the seam
|
|
790
|
+
* does with them is the seam's business, and today it does nothing: v2 derives a
|
|
791
|
+
* contract's lifecycle from its dates, so an `EffectiveDate` on a contract nobody has
|
|
792
|
+
* approved yet would make it read as live and in force. `LiveContractsSeam` records the
|
|
793
|
+
* reasoning in full; the point here is that a reader of this method should not conclude
|
|
794
|
+
* these land on the agreement.
|
|
795
|
+
*/
|
|
796
|
+
ExecutionDate: deal.ExecutionDate ? String(deal.ExecutionDate) : null,
|
|
797
|
+
StartDate: deal.StartDate ? String(deal.StartDate) : null,
|
|
798
|
+
// The buying party, so contracts can stamp the customer without looking back at the deal.
|
|
799
|
+
AccountID: deal.AccountID,
|
|
800
|
+
PrimaryContactID: deal.PrimaryContactID,
|
|
801
|
+
/**
|
|
802
|
+
* NO `BillingFrequency`, NO `CommittedAmount`, NO `Lines` — all three now have nowhere to
|
|
803
|
+
* go rather than merely being unset.
|
|
804
|
+
*
|
|
805
|
+
* The 2026-08-18 contracts rebuild deleted `ContractTerm` and `ContractLine` outright: v2's
|
|
806
|
+
* contract IS the header. Billing frequency and the committed amount were TERM columns and
|
|
807
|
+
* line items were the redundancy the rework removed, so the earlier note here — that the
|
|
808
|
+
* seam would apply its own billing-frequency default and that contracts' line retirement
|
|
809
|
+
* was "the other half of it, on their side" — describes a schema that no longer exists.
|
|
810
|
+
* Their half landed; this is ours.
|
|
811
|
+
*/
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
CloseDealOperation = __decorate([
|
|
816
|
+
RegisterClass(BaseRemotableOperation, 'Sales.CloseDeal')
|
|
817
|
+
], CloseDealOperation);
|
|
818
|
+
export { CloseDealOperation };
|
|
819
|
+
/**
|
|
820
|
+
* `Sales.ReopenDeal` — the only sanctioned exit from the close lock (§7.3).
|
|
821
|
+
*
|
|
822
|
+
* Writes its own `DealStageEvent` first, so the fact that a close HAPPENED survives the close being
|
|
823
|
+
* undone. Provenance is append-only: reopening adds to the record rather than erasing it.
|
|
824
|
+
*/
|
|
825
|
+
let ReopenDealOperation = class ReopenDealOperation extends SalesReopenDealOperationBase {
|
|
826
|
+
async InternalExecute(input, provider, user) {
|
|
827
|
+
const empty = { Success: false, Issues: [], Unlocked: false };
|
|
828
|
+
if (!input?.DealID) {
|
|
829
|
+
return { ...empty, Issues: [issue('deal', 'A deal is required.', 'DealID')] };
|
|
830
|
+
}
|
|
831
|
+
// §7.3 makes this mandatory: undoing a lock has to be explainable.
|
|
832
|
+
if (!input.Reason?.trim()) {
|
|
833
|
+
return { ...empty, Issues: [issue('deal', 'A reason is required to reopen a closed deal.', 'Reason')] };
|
|
834
|
+
}
|
|
835
|
+
const db = provider;
|
|
836
|
+
let transactionOpen = false;
|
|
837
|
+
try {
|
|
838
|
+
const deal = await provider.GetEntityObject(DEAL_ENTITY, user);
|
|
839
|
+
if (!(await deal.Load(input.DealID))) {
|
|
840
|
+
return { ...empty, Issues: [issue('deal', `Deal ${input.DealID} was not found.`, 'DealID')] };
|
|
841
|
+
}
|
|
842
|
+
const target = input.DealStatusTypeID
|
|
843
|
+
? await this.resolveTarget(input.DealStatusTypeID, provider, user)
|
|
844
|
+
: await this.firstOpenStatus(provider, user);
|
|
845
|
+
if (!target) {
|
|
846
|
+
return {
|
|
847
|
+
...empty,
|
|
848
|
+
Issues: [
|
|
849
|
+
issue('deal', 'No open status is available to reopen into. Seed a DealStatusType with IsOpen = 1.', 'DealStatusTypeID'),
|
|
850
|
+
],
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
if (target.LocksDeal) {
|
|
854
|
+
return {
|
|
855
|
+
...empty,
|
|
856
|
+
Issues: [issue('deal', 'Cannot reopen into a status that locks the deal.', 'DealStatusTypeID')],
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* ── DN-20: A BOOKED ORDER REFUSES THE REOPEN OUTRIGHT ───────────────────────────────
|
|
861
|
+
*
|
|
862
|
+
* Raised by Finance. Until now a reopen succeeded whatever state the order was in: it
|
|
863
|
+
* restored the prior stage and reported what the order refused. That is not enough once the
|
|
864
|
+
* order has BOOKED, because the ledger has moved -- the correct instrument is then an
|
|
865
|
+
* order-side one, and editing the deal behind a live receivable is not it.
|
|
866
|
+
*
|
|
867
|
+
* ── WHY `IsBooked` AND NOT THE TWO OBVIOUS TESTS ────────────────────────────────────
|
|
868
|
+
*
|
|
869
|
+
* Both alternatives are wrong, in opposite directions, and the data on the host showed it:
|
|
870
|
+
*
|
|
871
|
+
* `Status === 'Confirmed'` UNDER-blocks. The lifecycle runs on to `Posted` and then
|
|
872
|
+
* `Fulfilled`, so an equality test lets through precisely the orders whose ledger has
|
|
873
|
+
* moved FURTHEST. It is also a string comparison against orders' vocabulary, which is the
|
|
874
|
+
* shape rule two forbids.
|
|
875
|
+
*
|
|
876
|
+
* `ConfirmedAt IS NOT NULL` OVER-blocks. That column is a timestamp, and an order that was
|
|
877
|
+
* confirmed and later VOIDED keeps it forever. Refusing there would block the one case
|
|
878
|
+
* where the ledger has been SETTLED: orders records a reversal as its own record (D53), so
|
|
879
|
+
* a voided order is not a live receivable and the deal behind it is safe to reopen.
|
|
880
|
+
*
|
|
881
|
+
* `IsBooked` is the only one of the three that means "the ledger currently stands", and it
|
|
882
|
+
* gives the confirmed-then-voided case the right answer for free -- `IsBooked('Voided')` is
|
|
883
|
+
* false. The two directions are the point: a later "simplification" to
|
|
884
|
+
* `status === 'Confirmed'` breaks both, and `close-deal.CD23` exists to catch that.
|
|
885
|
+
*
|
|
886
|
+
* ── WHAT IT SAYS, AND WHAT IT DELIBERATELY DOES NOT ─────────────────────────────────
|
|
887
|
+
*
|
|
888
|
+
* The message states only what sales owns: that it refuses, which order, orders' own verdict,
|
|
889
|
+
* and an explicit handoff. It names NO remedy. The right instrument differs by status -- a
|
|
890
|
+
* `Fulfilled` order is a void plus a return order, a `Confirmed` one is not -- and sales
|
|
891
|
+
* narrating another app's workflow would be worse than an honest handoff. If a named remedy
|
|
892
|
+
* is wanted later, orders exports it and sales quotes it, the way it quotes
|
|
893
|
+
* `CanTransition`'s `Reason` today.
|
|
894
|
+
*
|
|
895
|
+
* A deal with NO order has no peer here and is not refused, which is the correct reading:
|
|
896
|
+
* there is no ledger to have moved.
|
|
897
|
+
*/
|
|
898
|
+
/**
|
|
899
|
+
* ── AN UNREADABLE PEER IS REFUSED, NOT WAVED THROUGH ────────────────────────
|
|
900
|
+
*
|
|
901
|
+
* `OrderID_Object` returns silently UNEXPOSED when the peer did not load —
|
|
902
|
+
* `EmbeddedRecord.LoadEager` gates `Value` behind a private flag and does not throw. So
|
|
903
|
+
* `order` was undefined, `orderStatus` was null, and the refusal was SKIPPED for exactly the
|
|
904
|
+
* state it should refuse hardest: a deal that points at an order nobody can read. The
|
|
905
|
+
* booked-order guard silently became "no order, carry on".
|
|
906
|
+
*
|
|
907
|
+
* `deal.OrderID` is the discriminator and it is on the deal's own row, so it is readable even
|
|
908
|
+
* when the peer is not. FK set + peer unresolved is not "no order" — it is "unknown
|
|
909
|
+
* order", and an unknown ledger state cannot be certified safe to reopen.
|
|
910
|
+
*
|
|
911
|
+
* THE SIBLING CALL SITE ALREADY DECIDED THIS. `DealWorkspaceService.LoadDeal` treats the same
|
|
912
|
+
* state as a hard failure and refuses to render, on the reasoning that a deal with an
|
|
913
|
+
* unreadable order must not be shown as a deal with no lines. Two files reading one state and
|
|
914
|
+
* returning opposite verdicts is the defect; this makes them agree.
|
|
915
|
+
*/
|
|
916
|
+
if (deal.OrderID && !deal.OrderID_Object) {
|
|
917
|
+
return {
|
|
918
|
+
...empty,
|
|
919
|
+
Issues: [
|
|
920
|
+
issue('deal', `This deal cannot be reopened: it points at order ${deal.OrderID}, which could `
|
|
921
|
+
+ `not be read, so whether that order has booked is unknown. Reopening on an `
|
|
922
|
+
+ `unknown ledger state is the one thing this refusal exists to prevent. `
|
|
923
|
+
+ `Try again, and if it persists the order is the thing to look at.`, 'DealID'),
|
|
924
|
+
],
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const order = deal.OrderID_Object;
|
|
928
|
+
const orderStatus = order?.Status ?? null;
|
|
929
|
+
if (orderStatus && IsBooked(orderStatus)) {
|
|
930
|
+
return {
|
|
931
|
+
...empty,
|
|
932
|
+
Issues: [
|
|
933
|
+
issue('deal', `This deal cannot be reopened: order ${order?.OrderNumber ?? '(unnumbered)'} has `
|
|
934
|
+
+ `booked, so the ledger has already moved. Reopening would edit a deal whose `
|
|
935
|
+
+ `order can no longer be edited. What to do about the order is an orders `
|
|
936
|
+
+ `decision — resolve it there first, then reopen this deal.`, 'DealID'),
|
|
937
|
+
],
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
await db.BeginTransaction();
|
|
941
|
+
transactionOpen = true;
|
|
942
|
+
/**
|
|
943
|
+
* ── SAME MECHANISM AS THE CLOSE, AND FOR A THIRD REASON BESIDES THE DUPLICATE ROW ────────
|
|
944
|
+
*
|
|
945
|
+
* This hand-wrote its event and then moved the stage, so a reopen that named a `StageID` got
|
|
946
|
+
* two rows exactly as the close did. It also invoked the stage-DEFAULTS writer without meaning
|
|
947
|
+
* to: the save re-derived `Probability` from the arriving stage, AFTER the event above had
|
|
948
|
+
* stamped the value being left behind. The reopen's own provenance row disagreed with the deal
|
|
949
|
+
* it described, and nothing was comparing the two.
|
|
950
|
+
*
|
|
951
|
+
* A declared transition answers both — one row, and the defaults writer stands down because
|
|
952
|
+
* the probability on a reopened deal is a human's judgement, not the pipeline's default.
|
|
953
|
+
*/
|
|
954
|
+
deal.DeclareTransition('Reopen', `REOPENED: ${input.Reason.trim()}`);
|
|
955
|
+
deal.DealStatusTypeID = target.ID;
|
|
956
|
+
/**
|
|
957
|
+
* THE PRIOR STAGE, DERIVED WHEN THE CALLER DID NOT NAME ONE — DN-18.
|
|
958
|
+
*
|
|
959
|
+
* `input.StageID` stays an override: a caller who knows where the deal should land says so.
|
|
960
|
+
* Absent that, the close event says where it came from, and restoring it is what makes
|
|
961
|
+
* `OrderStatusOnEntry` fire so the order is asked to come back. A status-only close left the
|
|
962
|
+
* stage where it was, so this resolves to the stage the deal is already in and nothing fires.
|
|
963
|
+
*/
|
|
964
|
+
const landingStage = input.StageID ?? (await this.priorStageFromCloseEvent(deal.ID, provider, user));
|
|
965
|
+
if (landingStage) {
|
|
966
|
+
deal.PipelineStageID = landingStage;
|
|
967
|
+
}
|
|
968
|
+
// The close stamps describe a close that is no longer in effect; clearing them keeps every
|
|
969
|
+
// rollup that tests `ClosedAt IS NOT NULL` honest. The event above preserves the history
|
|
970
|
+
// (CLOSE-FLOW-DECISIONS.md D-CF8).
|
|
971
|
+
deal.ClosedAt = null;
|
|
972
|
+
deal.ClosedByUserID = null;
|
|
973
|
+
deal.ActualCloseDate = null;
|
|
974
|
+
// The save has to write the very row the lock protects, so it runs with the lock suspended
|
|
975
|
+
// — scoped to this call and self-restoring.
|
|
976
|
+
const saved = await deal.BeginReopen(() => deal.Save());
|
|
977
|
+
if (!saved) {
|
|
978
|
+
throw new Error(`the deal could not be saved: ${deal.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
979
|
+
}
|
|
980
|
+
await db.CommitTransaction();
|
|
981
|
+
transactionOpen = false;
|
|
982
|
+
// The reopen SUCCEEDED even when the order refused to come back with it. That is the
|
|
983
|
+
// designed outcome, not a tolerated one: S-US8's reopen enters a stage asking for `Quoted`
|
|
984
|
+
// while the order sits at `Voided`, which orders treats as terminal. The deal reopens and
|
|
985
|
+
// says what did not happen.
|
|
986
|
+
return {
|
|
987
|
+
Success: true,
|
|
988
|
+
Issues: orderStatusIssues(deal),
|
|
989
|
+
Unlocked: true,
|
|
990
|
+
// Appended by the save under the declaration above, so read from the deal afterwards.
|
|
991
|
+
DealStageEventID: deal.LastStageEventID,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
if (transactionOpen) {
|
|
996
|
+
try {
|
|
997
|
+
await provider.RollbackTransaction();
|
|
998
|
+
}
|
|
999
|
+
catch (rollbackErr) {
|
|
1000
|
+
LogError(`Sales.ReopenDeal: rollback failed: ${rollbackErr}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
LogError(`Sales.ReopenDeal failed: ${err}`);
|
|
1004
|
+
return { ...empty, Issues: [issue('deal', `The deal could not be reopened: ${err}`)] };
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async resolveTarget(statusID, provider, user) {
|
|
1008
|
+
const view = provider;
|
|
1009
|
+
const r = await view.RunView({
|
|
1010
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
1011
|
+
ExtraFilter: `ID = '${statusID}'`,
|
|
1012
|
+
ResultType: 'simple',
|
|
1013
|
+
Fields: ['ID', 'LocksDeal'],
|
|
1014
|
+
}, user);
|
|
1015
|
+
const row = (r.Results ?? [])[0];
|
|
1016
|
+
return r.Success && row ? { ID: String(row.ID), LocksDeal: row.LocksDeal === true } : null;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The stage a deal was in before it was closed, from the append-only log.
|
|
1020
|
+
*
|
|
1021
|
+
* ── WHY THE OPERATION DERIVES THIS AND THE WORKSPACE DOES NOT SUPPLY IT ─────────────────────────
|
|
1022
|
+
*
|
|
1023
|
+
* DN-18. The story says a reopened deal returns to its prior stage; `DealStageEvent.FromStageID`
|
|
1024
|
+
* records exactly that, and `input.StageID` already existed. The only missing piece was that nobody
|
|
1025
|
+
* sent it — so nothing ever re-entered a stage, `OrderStatusOnEntry` never fired, and a reopened deal
|
|
1026
|
+
* sat pointing at a `Voided` order with nothing on screen saying so.
|
|
1027
|
+
*
|
|
1028
|
+
* Deriving it HERE rather than in the workspace keeps `input.StageID` an override instead of a
|
|
1029
|
+
* requirement, and means an agent, an importer and an API caller get the same restoration a rep does.
|
|
1030
|
+
* That is the same write-path principle as the deal number, the company stamp, the owner stamp and the
|
|
1031
|
+
* embedded order: if the rule is the app's, the server owns it.
|
|
1032
|
+
*
|
|
1033
|
+
* ── WHICH EVENT, AND BY FLAG RATHER THAN BY NAME ────────────────────────────────────────────────
|
|
1034
|
+
*
|
|
1035
|
+
* The most recent event whose `ToDealStatusTypeID` carries `LocksDeal` — the closing transition. Read
|
|
1036
|
+
* as a set of status ids first because `RunView` cannot join, and selected by the flag so a pipeline
|
|
1037
|
+
* calling its winning stage "Signed" and its status "Booked" is unaffected (§3).
|
|
1038
|
+
*
|
|
1039
|
+
* A deal closed more than once has several such events; `ChangedAt DESC` takes the last one, which is
|
|
1040
|
+
* the close being undone.
|
|
1041
|
+
*
|
|
1042
|
+
* ── THE TWO CASES THAT MAKE THIS CORRECT RATHER THAN CONVENIENT ─────────────────────────────────
|
|
1043
|
+
*
|
|
1044
|
+
* · **A stage-moving close** stamped `FromStageID` = the stage before the close and `ToStageID` = the
|
|
1045
|
+
* closing stage. Restoring `FromStageID` moves the stage back, so the order-status writer fires
|
|
1046
|
+
* through the existing mechanism and the order follows — or refuses, and says so.
|
|
1047
|
+
* · **A status-only close** (no `ClosingStageID`) never moved the stage, so the event holds
|
|
1048
|
+
* `FromStageID === ToStageID`. Restoring it assigns the stage the deal is already in, the writer's
|
|
1049
|
+
* own comparison finds no move, and NOTHING fires. There is nothing to restore and nothing
|
|
1050
|
+
* happens — which is the behaviour that has to hold for this to be safe, and it holds without a
|
|
1051
|
+
* special case.
|
|
1052
|
+
*
|
|
1053
|
+
* Returns null when there is no close event to read — a deal closed by SQL, an import that wrote no
|
|
1054
|
+
* provenance, or a log that was never written. The reopen then leaves the stage alone rather than
|
|
1055
|
+
* guessing, which is the same answer as "the caller sent no StageID" and needs no extra branch.
|
|
1056
|
+
*/
|
|
1057
|
+
async priorStageFromCloseEvent(dealID, provider, user) {
|
|
1058
|
+
const view = provider;
|
|
1059
|
+
const locking = await view.RunView({
|
|
1060
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
1061
|
+
ExtraFilter: 'LocksDeal = 1',
|
|
1062
|
+
ResultType: 'simple',
|
|
1063
|
+
Fields: ['ID'],
|
|
1064
|
+
}, user);
|
|
1065
|
+
if (!locking.Success) {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
const ids = (locking.Results ?? [])
|
|
1069
|
+
.map((r) => String(r.ID))
|
|
1070
|
+
.filter(Boolean);
|
|
1071
|
+
if (ids.length === 0) {
|
|
1072
|
+
return null; // nothing in this host's vocabulary locks a deal, so nothing closed it
|
|
1073
|
+
}
|
|
1074
|
+
const quoted = ids.map((id) => `'${id}'`).join(', ');
|
|
1075
|
+
const events = await view.RunView({
|
|
1076
|
+
EntityName: STAGE_EVENT_ENTITY,
|
|
1077
|
+
ExtraFilter: `DealID = '${dealID}' AND ToDealStatusTypeID IN (${quoted})`,
|
|
1078
|
+
OrderBy: 'ChangedAt DESC',
|
|
1079
|
+
ResultType: 'simple',
|
|
1080
|
+
Fields: ['FromStageID', 'ChangedAt'],
|
|
1081
|
+
}, user);
|
|
1082
|
+
if (!events.Success) {
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
const row = (events.Results ?? [])[0];
|
|
1086
|
+
const from = row?.FromStageID;
|
|
1087
|
+
return from ? String(from) : null;
|
|
1088
|
+
}
|
|
1089
|
+
/** The default landing status — chosen by the `IsOpen` FLAG, never by a status name. */
|
|
1090
|
+
async firstOpenStatus(provider, user) {
|
|
1091
|
+
const view = provider;
|
|
1092
|
+
const r = await view.RunView({
|
|
1093
|
+
EntityName: DEAL_STATUS_ENTITY,
|
|
1094
|
+
ExtraFilter: 'IsOpen = 1 AND IsActive = 1',
|
|
1095
|
+
OrderBy: 'DisplayRank ASC',
|
|
1096
|
+
ResultType: 'simple',
|
|
1097
|
+
Fields: ['ID', 'LocksDeal'],
|
|
1098
|
+
}, user);
|
|
1099
|
+
const row = (r.Results ?? [])[0];
|
|
1100
|
+
return r.Success && row ? { ID: String(row.ID), LocksDeal: row.LocksDeal === true } : null;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
ReopenDealOperation = __decorate([
|
|
1104
|
+
RegisterClass(BaseRemotableOperation, 'Sales.ReopenDeal')
|
|
1105
|
+
], ReopenDealOperation);
|
|
1106
|
+
export { ReopenDealOperation };
|
|
1107
|
+
//# sourceMappingURL=CloseDealOperation.js.map
|