@mj-biz-apps/sales-entities 6.4.0 → 6.6.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/dist/amount-freshness.d.ts +69 -0
- package/dist/amount-freshness.d.ts.map +1 -0
- package/dist/amount-freshness.js +100 -0
- package/dist/amount-freshness.js.map +1 -0
- package/dist/close-lock.d.ts +87 -41
- package/dist/close-lock.d.ts.map +1 -1
- package/dist/close-lock.js +192 -37
- package/dist/close-lock.js.map +1 -1
- package/dist/generated/entities/__mj_BizAppsSales.d.ts +4390 -0
- package/dist/generated/entities/__mj_BizAppsSales.d.ts.map +1 -0
- package/dist/generated/entities/__mj_BizAppsSales.js +6337 -0
- package/dist/generated/entities/__mj_BizAppsSales.js.map +1 -0
- package/dist/generated/entity_subclasses.d.ts +1 -4206
- package/dist/generated/entity_subclasses.d.ts.map +1 -1
- package/dist/generated/entity_subclasses.js +2 -6070
- package/dist/generated/entity_subclasses.js.map +1 -1
- package/dist/generated/remote_operations.d.ts +0 -2201
- package/dist/generated/remote_operations.d.ts.map +1 -1
- package/dist/generated/remote_operations.js +0 -611
- package/dist/generated/remote_operations.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Whether `Deal.Amount` still describes the order it was cached from.
|
|
3
|
+
*
|
|
4
|
+
* `Deal.Amount` is a CACHED ANSWER from Orders, stamped with `AmountIsComputed` / `AmountComputedAt` /
|
|
5
|
+
* `AmountSourceHash`. Sales never recomputes it. The only question a sales surface may ask is whether
|
|
6
|
+
* the cached number still matches the order — and that is a COMPARISON of two stored figures, not
|
|
7
|
+
* arithmetic. Nothing here multiplies, discounts, prorates, sums or rounds.
|
|
8
|
+
*
|
|
9
|
+
* ── WHY THIS IS NOT A TIMESTAMP COMPARE ANY MORE (bc-aidp-next-golive#230) ──────────────────────
|
|
10
|
+
*
|
|
11
|
+
* Both surfaces used to read the newest `__mj_UpdatedAt` across the order's lines and call the amount
|
|
12
|
+
* stale if it was later than `AmountComputedAt`. That asks "was a line TOUCHED", and the answer to that
|
|
13
|
+
* is yes for reasons that have nothing to do with the price: closing a deal books the order, which moves
|
|
14
|
+
* the lines' status and stamps `__mj_UpdatedAt` without a figure changing. So every Won deal warned,
|
|
15
|
+
* permanently, that its frozen amount needed repricing.
|
|
16
|
+
*
|
|
17
|
+
* It is the failure shape CLAUDE.md rule 8 names: a claim that was true when it was written -- a touched
|
|
18
|
+
* line usually DID mean a moved price -- and stayed asserted after the close flow started touching lines
|
|
19
|
+
* for its own reasons. The guard was keyed on a PROXY (the timestamp) rather than on the thing it
|
|
20
|
+
* actually cared about (the number). A proxy can be outgrown; the number cannot.
|
|
21
|
+
*
|
|
22
|
+
* So the test is now the one the question deserves: does the cached amount equal the order's current
|
|
23
|
+
* total? That is also EXACTLY the test `DealEntityServer.refreshAmountFromOrder()` uses to decide the
|
|
24
|
+
* cache is already current, so this surface and the server agree by construction rather than by
|
|
25
|
+
* coincidence — if the server would rewrite the cache, this says stale; if it would no-op, this says
|
|
26
|
+
* fresh.
|
|
27
|
+
*
|
|
28
|
+
* @module @mj-biz-apps/sales-entities
|
|
29
|
+
*/
|
|
30
|
+
import { type UserInfo } from '@memberjunction/core';
|
|
31
|
+
/**
|
|
32
|
+
* The notice a surface shows when the cached amount no longer matches its order.
|
|
33
|
+
*
|
|
34
|
+
* golive#230: the previous wording — "A line has changed since this amount was last priced. Reprice the
|
|
35
|
+
* order to update the total." — named an action that DOES NOT EXIST. There is no reprice control
|
|
36
|
+
* anywhere in this codebase, so the sentence asked the reader to do something impossible.
|
|
37
|
+
*
|
|
38
|
+
* Saving the deal genuinely is the fix, and that is verifiable rather than hopeful:
|
|
39
|
+
* `DealEntityServer.Save()` sets `amountMayHaveMoved` when `AmountIsComputed === true`, which is
|
|
40
|
+
* precisely the state this notice appears in, and then re-reads `OrderHeader.TotalGross` into the cache.
|
|
41
|
+
*/
|
|
42
|
+
export declare const DEAL_AMOUNT_STALE_NOTICE = "The products on this deal changed after the amount was calculated. Save the deal to update it.";
|
|
43
|
+
/** The answer, plus the notice to show. `Notice` is null whenever `IsStale` is false. */
|
|
44
|
+
export interface DealAmountFreshness {
|
|
45
|
+
IsStale: boolean;
|
|
46
|
+
Notice: string | null;
|
|
47
|
+
}
|
|
48
|
+
/** What the caller must already know about the deal. Both surfaces hold all four. */
|
|
49
|
+
export interface DealAmountFreshnessInput {
|
|
50
|
+
/**
|
|
51
|
+
* Whether the PERSISTED status locks the deal, as `ResolveDealLockState` reports it.
|
|
52
|
+
*
|
|
53
|
+
* Required rather than optional on purpose. A caller that forgot to pass it would get the old
|
|
54
|
+
* always-warns behaviour back, silently, on exactly the screen the bug was reported against.
|
|
55
|
+
*/
|
|
56
|
+
IsLocked: boolean;
|
|
57
|
+
AmountIsComputed: boolean | null | undefined;
|
|
58
|
+
Amount: number | string | null | undefined;
|
|
59
|
+
OrderID: string | null | undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether the deal's cached amount still matches its order's total.
|
|
63
|
+
*
|
|
64
|
+
* Returns FRESH — never warns — in every case where the question is meaningless: a locked deal (the
|
|
65
|
+
* amount is frozen and no edit could change it), a deal whose amount was typed rather than computed,
|
|
66
|
+
* a deal with no order, and an order with no usable total.
|
|
67
|
+
*/
|
|
68
|
+
export declare function ResolveDealAmountFreshness(input: DealAmountFreshnessInput, contextUser?: UserInfo): Promise<DealAmountFreshness>;
|
|
69
|
+
//# sourceMappingURL=amount-freshness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"amount-freshness.d.ts","sourceRoot":"","sources":["../src/amount-freshness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAW,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAI9D;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,mGAC+D,CAAC;AAErG,yFAAyF;AACzF,MAAM,WAAW,mBAAmB;IAChC,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,qFAAqF;AACrF,MAAM,WAAW,wBAAwB;IACrC;;;;;OAKG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB,gBAAgB,EAAE,OAAO,GAAG,IAAI,GAAG,SAAS,CAAC;IAC7C,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC3C,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACtC;AAKD;;;;;;GAMG;AACH,wBAAsB,0BAA0B,CAC5C,KAAK,EAAE,wBAAwB,EAC/B,WAAW,CAAC,EAAE,QAAQ,GACvB,OAAO,CAAC,mBAAmB,CAAC,CAsD9B"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Whether `Deal.Amount` still describes the order it was cached from.
|
|
3
|
+
*
|
|
4
|
+
* `Deal.Amount` is a CACHED ANSWER from Orders, stamped with `AmountIsComputed` / `AmountComputedAt` /
|
|
5
|
+
* `AmountSourceHash`. Sales never recomputes it. The only question a sales surface may ask is whether
|
|
6
|
+
* the cached number still matches the order — and that is a COMPARISON of two stored figures, not
|
|
7
|
+
* arithmetic. Nothing here multiplies, discounts, prorates, sums or rounds.
|
|
8
|
+
*
|
|
9
|
+
* ── WHY THIS IS NOT A TIMESTAMP COMPARE ANY MORE (bc-aidp-next-golive#230) ──────────────────────
|
|
10
|
+
*
|
|
11
|
+
* Both surfaces used to read the newest `__mj_UpdatedAt` across the order's lines and call the amount
|
|
12
|
+
* stale if it was later than `AmountComputedAt`. That asks "was a line TOUCHED", and the answer to that
|
|
13
|
+
* is yes for reasons that have nothing to do with the price: closing a deal books the order, which moves
|
|
14
|
+
* the lines' status and stamps `__mj_UpdatedAt` without a figure changing. So every Won deal warned,
|
|
15
|
+
* permanently, that its frozen amount needed repricing.
|
|
16
|
+
*
|
|
17
|
+
* It is the failure shape CLAUDE.md rule 8 names: a claim that was true when it was written -- a touched
|
|
18
|
+
* line usually DID mean a moved price -- and stayed asserted after the close flow started touching lines
|
|
19
|
+
* for its own reasons. The guard was keyed on a PROXY (the timestamp) rather than on the thing it
|
|
20
|
+
* actually cared about (the number). A proxy can be outgrown; the number cannot.
|
|
21
|
+
*
|
|
22
|
+
* So the test is now the one the question deserves: does the cached amount equal the order's current
|
|
23
|
+
* total? That is also EXACTLY the test `DealEntityServer.refreshAmountFromOrder()` uses to decide the
|
|
24
|
+
* cache is already current, so this surface and the server agree by construction rather than by
|
|
25
|
+
* coincidence — if the server would rewrite the cache, this says stale; if it would no-op, this says
|
|
26
|
+
* fresh.
|
|
27
|
+
*
|
|
28
|
+
* @module @mj-biz-apps/sales-entities
|
|
29
|
+
*/
|
|
30
|
+
import { RunView } from '@memberjunction/core';
|
|
31
|
+
const E_ORDER_HEADER = 'MJ_BizApps_Orders: Order Headers';
|
|
32
|
+
/**
|
|
33
|
+
* The notice a surface shows when the cached amount no longer matches its order.
|
|
34
|
+
*
|
|
35
|
+
* golive#230: the previous wording — "A line has changed since this amount was last priced. Reprice the
|
|
36
|
+
* order to update the total." — named an action that DOES NOT EXIST. There is no reprice control
|
|
37
|
+
* anywhere in this codebase, so the sentence asked the reader to do something impossible.
|
|
38
|
+
*
|
|
39
|
+
* Saving the deal genuinely is the fix, and that is verifiable rather than hopeful:
|
|
40
|
+
* `DealEntityServer.Save()` sets `amountMayHaveMoved` when `AmountIsComputed === true`, which is
|
|
41
|
+
* precisely the state this notice appears in, and then re-reads `OrderHeader.TotalGross` into the cache.
|
|
42
|
+
*/
|
|
43
|
+
export const DEAL_AMOUNT_STALE_NOTICE = 'The products on this deal changed after the amount was calculated. Save the deal to update it.';
|
|
44
|
+
const FRESH = Object.freeze({ IsStale: false, Notice: null });
|
|
45
|
+
const STALE = Object.freeze({ IsStale: true, Notice: DEAL_AMOUNT_STALE_NOTICE });
|
|
46
|
+
/**
|
|
47
|
+
* Whether the deal's cached amount still matches its order's total.
|
|
48
|
+
*
|
|
49
|
+
* Returns FRESH — never warns — in every case where the question is meaningless: a locked deal (the
|
|
50
|
+
* amount is frozen and no edit could change it), a deal whose amount was typed rather than computed,
|
|
51
|
+
* a deal with no order, and an order with no usable total.
|
|
52
|
+
*/
|
|
53
|
+
export async function ResolveDealAmountFreshness(input, contextUser) {
|
|
54
|
+
/**
|
|
55
|
+
* A CLOSED DEAL NEVER WARNS, and this is the first test rather than one of several.
|
|
56
|
+
*
|
|
57
|
+
* The amount is frozen by the close lock, so there is no edit that could resolve the notice and no
|
|
58
|
+
* reason to read it. golive#230 is precisely this case: the close books the order, the order's
|
|
59
|
+
* lines move, and the deal is simultaneously forbidden from doing anything about it. A warning
|
|
60
|
+
* nobody can act on is worse than silence — it trains the reader to ignore the flag that matters.
|
|
61
|
+
*/
|
|
62
|
+
if (input.IsLocked) {
|
|
63
|
+
return FRESH;
|
|
64
|
+
}
|
|
65
|
+
// A typed amount claims nothing about the order, so it cannot disagree with one.
|
|
66
|
+
if (!input.AmountIsComputed || !input.OrderID) {
|
|
67
|
+
return FRESH;
|
|
68
|
+
}
|
|
69
|
+
const result = await new RunView().RunView({
|
|
70
|
+
EntityName: E_ORDER_HEADER,
|
|
71
|
+
ExtraFilter: `ID = '${String(input.OrderID).replace(/'/g, "''")}'`,
|
|
72
|
+
ResultType: 'simple',
|
|
73
|
+
Fields: ['TotalGross'],
|
|
74
|
+
}, contextUser);
|
|
75
|
+
if (!result?.Success) {
|
|
76
|
+
// A failed read is not evidence of staleness. Saying nothing is the honest answer.
|
|
77
|
+
return FRESH;
|
|
78
|
+
}
|
|
79
|
+
const raw = (result.Results ?? [])[0]?.TotalGross;
|
|
80
|
+
const total = raw === null || raw === undefined ? null : Number(raw);
|
|
81
|
+
if (total === null || !Number.isFinite(total)) {
|
|
82
|
+
/**
|
|
83
|
+
* `OrderHeader.TotalGross` is NULL for an order with no lines — `SUM` over no rows is NULL —
|
|
84
|
+
* so this is the empty-order case, not an error. There is no total to disagree with.
|
|
85
|
+
*/
|
|
86
|
+
return FRESH;
|
|
87
|
+
}
|
|
88
|
+
const cachedRaw = input.Amount;
|
|
89
|
+
const cached = cachedRaw === null || cachedRaw === undefined ? null : Number(cachedRaw);
|
|
90
|
+
if (cached === null || !Number.isFinite(cached)) {
|
|
91
|
+
/**
|
|
92
|
+
* A deal claiming a COMPUTED amount that is not a number is the corrupt state `SD23` was written
|
|
93
|
+
* for. Reporting it stale is both true and useful: the notice's own instruction — save the deal
|
|
94
|
+
* — is exactly what repairs it, because the save re-reads the total into the cache.
|
|
95
|
+
*/
|
|
96
|
+
return STALE;
|
|
97
|
+
}
|
|
98
|
+
return cached === total ? FRESH : STALE;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=amount-freshness.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"amount-freshness.js","sourceRoot":"","sources":["../src/amount-freshness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAE,OAAO,EAAiB,MAAM,sBAAsB,CAAC;AAE9D,MAAM,cAAc,GAAG,kCAAkC,CAAC;AAE1D;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,wBAAwB,GACjC,gGAAgG,CAAC;AAsBrG,MAAM,KAAK,GAAwB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;AACnF,MAAM,KAAK,GAAwB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,wBAAwB,EAAE,CAAC,CAAC;AAEtG;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC5C,KAA+B,EAC/B,WAAsB;IAEtB;;;;;;;OAOG;IACH,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;QACjB,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,iFAAiF;IACjF,IAAI,CAAC,KAAK,CAAC,gBAAgB,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC;QAC5C,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,IAAI,OAAO,EAAE,CAAC,OAAO,CACtC;QACI,UAAU,EAAE,cAAc;QAC1B,WAAW,EAAE,SAAS,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG;QAClE,UAAU,EAAE,QAAQ;QACpB,MAAM,EAAE,CAAC,YAAY,CAAC;KACzB,EACD,WAAW,CACd,CAAC;IACF,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,CAAC;QACnB,mFAAmF;QACnF,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC;IAClD,MAAM,KAAK,GAAG,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACrE,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC5C;;;WAGG;QACH,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC;IAC/B,MAAM,MAAM,GAAG,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;IACxF,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9C;;;;WAIG;QACH,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,OAAO,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;AAC5C,CAAC"}
|
package/dist/close-lock.d.ts
CHANGED
|
@@ -28,25 +28,81 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { type UserInfo } from '@memberjunction/core';
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* Every field a locked deal still accepts, given its outcome.
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Takes the flag rather than exposing a flat set, so a caller cannot read the list and forget the
|
|
34
|
+
* condition. That is not hypothetical: four call sites used to read the flat constant directly.
|
|
35
|
+
*
|
|
36
|
+
* @param isLost - whether the deal's PERSISTED status carries `IsLost`. When it cannot be determined,
|
|
37
|
+
* pass `false`: refusing an edit to a closed deal is the cheaper mistake.
|
|
38
|
+
*/
|
|
39
|
+
export declare function DealFieldsEditableWhileLocked(isLost: boolean): ReadonlySet<string>;
|
|
40
|
+
/**
|
|
41
|
+
* Whether `fieldName` may still be edited on a locked deal with this outcome.
|
|
42
|
+
*
|
|
43
|
+
* Callers should prefer this to reaching into the sets, so the membership test stays in one place.
|
|
44
|
+
*/
|
|
45
|
+
export declare function IsDealFieldEditableWhileLocked(fieldName: string, isLost: boolean): boolean;
|
|
46
|
+
/** The on-screen label for a deal field: the override if there is one, else the field name split into words. */
|
|
47
|
+
export declare function DealFieldLabel(fieldName: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* What the lock notice lists as still editable.
|
|
50
|
+
*
|
|
51
|
+
* DEAL STATUS IS IN HERE AND NOT IN `DealFieldsEditableWhileLocked`, which looks like a
|
|
52
|
+
* contradiction and is not. That set is what the SERVER accepts in a bare save, and golive#205 asks
|
|
53
|
+
* for a bare status write to be refused on every path — the status moves through `Sales.CloseDeal`
|
|
54
|
+
* and `Sales.ReopenDeal`, which the form's status control routes to. So the field IS editable to a
|
|
55
|
+
* person and IS NOT writable by a raw save, and one set cannot say both.
|
|
56
|
+
*
|
|
57
|
+
* The notice describes what a PERSON can do, so it is the one that carries Deal Status.
|
|
58
|
+
*
|
|
59
|
+
* It takes `isLost` because the set it wraps does: golive#206 keeps Loss Notes editable on a lost
|
|
60
|
+
* deal and frozen on a won one, so the notice must name it on one and not the other.
|
|
36
61
|
*/
|
|
37
|
-
export declare
|
|
62
|
+
export declare function DealFieldsListedAsEditable(isLost: boolean): readonly string[];
|
|
38
63
|
/**
|
|
39
|
-
*
|
|
64
|
+
* Join labels the way a sentence does: "A, B and C".
|
|
40
65
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
66
|
+
* Oxford-comma-free and with "and" before the last, because this lands mid-sentence in prose a tester
|
|
67
|
+
* wrote, not in a bulleted list.
|
|
43
68
|
*/
|
|
44
|
-
export declare function
|
|
69
|
+
export declare function JoinLabels(labels: readonly string[]): string;
|
|
45
70
|
/** What a surface needs to know to render the lock: whether it is on, and what to say about it. */
|
|
46
71
|
export interface DealLockState {
|
|
47
72
|
IsLocked: boolean;
|
|
48
73
|
/** The status' display name, for the notice. Null when not locked. */
|
|
49
74
|
StatusName: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Whether the locking status carries `IsLost`, which decides one field: golive#206 keeps Loss
|
|
77
|
+
* Notes editable on a lost deal and frozen on a won one.
|
|
78
|
+
*
|
|
79
|
+
* Resolved HERE rather than by each surface, for the same reason `IsLocked` is: it is read off the
|
|
80
|
+
* status ROW by flag, from the PERSISTED status, and a second implementation gets one of those
|
|
81
|
+
* quietly wrong. `false` on an open deal and on a status that cannot be read — refusing an edit to
|
|
82
|
+
* a closed deal is the cheaper mistake.
|
|
83
|
+
*/
|
|
84
|
+
IsLost: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Whether the PERSISTED status carries `IsWon` — the outcome, not the lock.
|
|
87
|
+
*
|
|
88
|
+
* UNLIKE EVERY OTHER MEMBER HERE, THIS IS NOT GATED ON `LocksDeal`, and the difference is the
|
|
89
|
+
* whole reason it exists. The rest of this shape answers "what may still be edited", a question
|
|
90
|
+
* only a locked deal has. `IsWon` answers "did we win", which an OPEN deal can also answer, and
|
|
91
|
+
* golive#226 asks the Deal header for chips that appear on a won deal and on no other. Gating it
|
|
92
|
+
* on the lock would tie a header decision to a field-editing one, so a deployment whose winning
|
|
93
|
+
* status does not freeze the deal would lose its Order and Contract chips with nothing on screen
|
|
94
|
+
* to explain it.
|
|
95
|
+
*
|
|
96
|
+
* Read off the status ROW by FLAG, like its siblings. Nothing anywhere compares a status name —
|
|
97
|
+
* a deployment may call its winning status "Signed" (§3), and `test:vocabulary-gate` enforces it.
|
|
98
|
+
*
|
|
99
|
+
* `false` when the status cannot be read: a chip that is missing costs a click, and one that
|
|
100
|
+
* should not be there says a deal was won when it was not.
|
|
101
|
+
*
|
|
102
|
+
* golive#231's outcome tiles read it too, and are unaffected by the ungating: every one of those
|
|
103
|
+
* getters tests the CLOSE STAMPS first, so an open won-status deal still reads "Closes".
|
|
104
|
+
*/
|
|
105
|
+
IsWon: boolean;
|
|
50
106
|
/** A ready-to-render explanation, or null when the deal is open. */
|
|
51
107
|
Notice: string | null;
|
|
52
108
|
}
|
|
@@ -69,41 +125,31 @@ export interface DealLockState {
|
|
|
69
125
|
* @param contextUser - Server callers must pass one; the browser omits it.
|
|
70
126
|
*/
|
|
71
127
|
export declare function ResolveDealLockState(persistedStatusID: string | null | undefined, contextUser?: UserInfo): Promise<DealLockState>;
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
StatusIsDirty: boolean;
|
|
85
|
-
/** Whether the status being moved INTO carries `LocksDeal`. */
|
|
86
|
-
TargetLocks: boolean;
|
|
87
|
-
/** Whether the status being moved OUT OF carries it. Null when there was none. */
|
|
88
|
-
PriorLocks: boolean | null;
|
|
128
|
+
/** One row of the deal-status list, with the flag that decides whether it may be picked. */
|
|
129
|
+
export interface DealStatusOption {
|
|
130
|
+
ID: string;
|
|
131
|
+
Name: string;
|
|
132
|
+
/** Entering this status closes and freezes the deal. Enforced server-side. */
|
|
133
|
+
LocksDeal: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* The OUTCOME flags, carried so a close action can find the status to close INTO by flag rather
|
|
136
|
+
* than by name. A deployment may call its winning status "Signed"; nothing may match on the word.
|
|
137
|
+
*/
|
|
138
|
+
IsWon: boolean;
|
|
139
|
+
IsLost: boolean;
|
|
89
140
|
}
|
|
90
141
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* THE DEFECT IT NAMES. The close lock reads the PERSISTED status, so Open -> Won is a save on an
|
|
94
|
-
* unlocked deal and passes straight through it. The deal ends up locked with none of the close having
|
|
95
|
-
* happened — no stage event, no contract, no finance tasks, no loss reason, an order still live — and
|
|
96
|
-
* the lock then refuses `DealStatusTypeID` on every later save, so it cannot be undone either.
|
|
97
|
-
*
|
|
98
|
-
* WHAT MAKES A CLOSE LEGITIMATE is the declared transition. `Sales.CloseDeal` calls `stampClose`
|
|
99
|
-
* immediately before saving, and that declares one; a form writing a field has no way to. So this
|
|
100
|
-
* needs no new flag on the entity, and it cannot be spoofed by a caller that does not know about it.
|
|
142
|
+
* Every active deal status, with its lock flag, for a surface that has to offer a choice.
|
|
101
143
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
144
|
+
* RETURNS ALL OF THEM AND LETS THE CALLER FILTER, deliberately. A closed deal still has to SHOW the
|
|
145
|
+
* status it is in — a control that simply dropped the locking ones would render a won deal as blank
|
|
146
|
+
* or "— choose —", which reads as data loss. The workspace already solves it this way: it offers the
|
|
147
|
+
* non-locking ones and adds the deal's own status back as a display-only option.
|
|
105
148
|
*
|
|
106
|
-
*
|
|
149
|
+
* `LocksDeal` rather than `IsWon || IsLost`, because that is the flag the server's refusal reads.
|
|
150
|
+
* They coincide on today's data — Won, Lost and Abandoned carry both — but a surface filtering on a
|
|
151
|
+
* different flag would eventually offer a status the server then refuses, which is the drift this
|
|
152
|
+
* module exists to prevent.
|
|
107
153
|
*/
|
|
108
|
-
export declare function
|
|
154
|
+
export declare function LoadDealStatusOptions(contextUser?: UserInfo): Promise<DealStatusOption[]>;
|
|
109
155
|
//# sourceMappingURL=close-lock.d.ts.map
|
package/dist/close-lock.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"close-lock.d.ts","sourceRoot":"","sources":["../src/close-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAW,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"close-lock.d.ts","sourceRoot":"","sources":["../src/close-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAW,KAAK,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AA0C9D;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,OAAO,GAAG,WAAW,CAAC,MAAM,CAAC,CAKlF;AAED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAE1F;AAkDD,gHAAgH;AAChH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,MAAM,EAAE,CAE7E;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAI5D;AAKD,mGAAmG;AACnG,MAAM,WAAW,aAAa;IAC1B,QAAQ,EAAE,OAAO,CAAC;IAClB,sEAAsE;IACtE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;;;;OAQG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,KAAK,EAAE,OAAO,CAAC;IACf,oEAAoE;IACpE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,oBAAoB,CACtC,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC5C,WAAW,CAAC,EAAE,QAAQ,GACvB,OAAO,CAAC,aAAa,CAAC,CAqDxB;AAED,4FAA4F;AAC5F,MAAM,WAAW,gBAAgB;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,SAAS,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,qBAAqB,CAAC,WAAW,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAiC/F"}
|
package/dist/close-lock.js
CHANGED
|
@@ -28,24 +28,145 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { RunView } from '@memberjunction/core';
|
|
30
30
|
/**
|
|
31
|
-
* The deal fields that stay editable while the deal is locked.
|
|
31
|
+
* The deal fields that stay editable while the deal is locked, on ANY locked deal.
|
|
32
32
|
*
|
|
33
33
|
* Pinned by integration check CD14, which closes a deal and then proves each of these is genuinely
|
|
34
|
-
* accepted and that a field outside the set is genuinely refused — so this
|
|
35
|
-
*
|
|
34
|
+
* accepted and that a field outside the set is genuinely refused — so this cannot quietly stop
|
|
35
|
+
* describing what the server does.
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
const EDITABLE_ON_ANY_LOCKED_DEAL = new Set([
|
|
38
|
+
// Commentary. A closed deal still gets notes, and forcing a reopen to add one would corrupt the
|
|
39
|
+
// reopen record with administrative noise.
|
|
38
40
|
'Description',
|
|
41
|
+
// Follow-up. NextStepDate is here because leaving one of the pair open and the other frozen makes
|
|
42
|
+
// no sense -- a next step nobody may date is half a field.
|
|
39
43
|
'NextStep',
|
|
44
|
+
'NextStepDate',
|
|
45
|
+
// Attribution and bookkeeping. Nothing downstream reads these, and they are routinely corrected
|
|
46
|
+
// after the fact: the contract takes the PRIMARY contact, not the billing one, and Lead Source and
|
|
47
|
+
// Campaign exist for reporting.
|
|
48
|
+
'BillingContactID',
|
|
49
|
+
'LeadSourceTypeID',
|
|
50
|
+
'CampaignID',
|
|
40
51
|
]);
|
|
41
52
|
/**
|
|
42
|
-
*
|
|
53
|
+
* The fields that stay editable only on a LOST deal.
|
|
43
54
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
55
|
+
* golive#206's field table says "Loss Notes (Lost deals only)", and item 3 asks that the server's
|
|
56
|
+
* list "match this list exactly". An earlier version of this module put `LossNotes` in the flat set
|
|
57
|
+
* above and said so out loud: a conditional member would need the form and the server to evaluate the
|
|
58
|
+
* same condition, which is the drift this module exists to prevent.
|
|
59
|
+
*
|
|
60
|
+
* That reasoning was weaker than it read. THIS module is exactly where such a condition belongs --
|
|
61
|
+
* the same place that already owns "how you decide a deal is locked at all". Both sides now pass a
|
|
62
|
+
* flag they already hold, from the same resolver, and the condition itself lives here once.
|
|
63
|
+
*
|
|
64
|
+
* `LossReasonID` stays frozen on every deal, lost included: the close event records which reason was
|
|
65
|
+
* chosen, and rewriting it would make that event dishonest. Notes are the channel for corrections.
|
|
66
|
+
*/
|
|
67
|
+
const EDITABLE_ON_A_LOST_DEAL = new Set(['LossNotes']);
|
|
68
|
+
/**
|
|
69
|
+
* Every field a locked deal still accepts, given its outcome.
|
|
70
|
+
*
|
|
71
|
+
* Takes the flag rather than exposing a flat set, so a caller cannot read the list and forget the
|
|
72
|
+
* condition. That is not hypothetical: four call sites used to read the flat constant directly.
|
|
73
|
+
*
|
|
74
|
+
* @param isLost - whether the deal's PERSISTED status carries `IsLost`. When it cannot be determined,
|
|
75
|
+
* pass `false`: refusing an edit to a closed deal is the cheaper mistake.
|
|
76
|
+
*/
|
|
77
|
+
export function DealFieldsEditableWhileLocked(isLost) {
|
|
78
|
+
if (!isLost) {
|
|
79
|
+
return EDITABLE_ON_ANY_LOCKED_DEAL;
|
|
80
|
+
}
|
|
81
|
+
return new Set([...EDITABLE_ON_ANY_LOCKED_DEAL, ...EDITABLE_ON_A_LOST_DEAL]);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether `fieldName` may still be edited on a locked deal with this outcome.
|
|
85
|
+
*
|
|
86
|
+
* Callers should prefer this to reaching into the sets, so the membership test stays in one place.
|
|
87
|
+
*/
|
|
88
|
+
export function IsDealFieldEditableWhileLocked(fieldName, isLost) {
|
|
89
|
+
return EDITABLE_ON_ANY_LOCKED_DEAL.has(fieldName) || (isLost && EDITABLE_ON_A_LOST_DEAL.has(fieldName));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* What each editable-while-locked field is CALLED on screen.
|
|
93
|
+
*
|
|
94
|
+
* The lock notice used to interpolate the raw field names, so a user read "only Description and
|
|
95
|
+
* NextStep can still be changed" — `NextStep` being a column name that appears nowhere on the form.
|
|
96
|
+
* golive#207 asks for the labels a salesperson sees.
|
|
97
|
+
*
|
|
98
|
+
* Falling back to the field name is deliberate rather than throwing: a field added to the set without
|
|
99
|
+
* a label here should read slightly wrong, not take the whole notice down. `DealFieldLabel` is
|
|
100
|
+
* exported so a test can prove every member of the set has one.
|
|
101
|
+
*/
|
|
102
|
+
const DEAL_FIELD_LABELS = {
|
|
103
|
+
Description: 'Description',
|
|
104
|
+
NextStep: 'Next Step',
|
|
105
|
+
NextStepDate: 'Next Step Date',
|
|
106
|
+
BillingContactID: 'Billing Contact',
|
|
107
|
+
LeadSourceTypeID: 'Lead Source',
|
|
108
|
+
CampaignID: 'Campaign',
|
|
109
|
+
LossNotes: 'Loss Notes',
|
|
110
|
+
DealStatusTypeID: 'Deal Status',
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Splits a column name into words and drops a trailing `ID`: `ExpectedCloseDate` -> `Expected Close
|
|
114
|
+
* Date`, `BillingContactID` -> `Billing Contact`.
|
|
115
|
+
*
|
|
116
|
+
* THIS EXISTS BECAUSE THE MAP ABOVE COVERS THE WRONG HALF OF THE RULE. It holds the fields the lock
|
|
117
|
+
* leaves EDITABLE -- what row 16 lists. Row 18 names the FROZEN fields, and there are far more of
|
|
118
|
+
* those, none of them in the map: `Amount`, `ExpectedCloseDate`, `AnnualIncreasePctOverride` and every
|
|
119
|
+
* other column a form or an importer can set. Falling back to the raw field name meant row 18 read
|
|
120
|
+
* "ExpectedCloseDate, AnnualIncreasePctOverride cannot be changed" -- the exact "NextStep being a
|
|
121
|
+
* column name that appears nowhere on the form" complaint the map was added to answer, on the message
|
|
122
|
+
* a user is far more likely to see.
|
|
123
|
+
*
|
|
124
|
+
* Deriving rather than hand-listing the frozen set, because a hand list would have to be extended
|
|
125
|
+
* every time a column is added and would read correctly right up until somebody forgot -- the same
|
|
126
|
+
* shape as the gap it is replacing. The map stays for the two labels a split cannot produce
|
|
127
|
+
* (`LeadSourceTypeID` -> "Lead Source", not "Lead Source Type") and is now an override rather than
|
|
128
|
+
* the only source of a label.
|
|
46
129
|
*/
|
|
47
|
-
|
|
48
|
-
return
|
|
130
|
+
function SplitFieldName(fieldName) {
|
|
131
|
+
return fieldName
|
|
132
|
+
.replace(/ID$/, '')
|
|
133
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
134
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
135
|
+
.trim();
|
|
136
|
+
}
|
|
137
|
+
/** The on-screen label for a deal field: the override if there is one, else the field name split into words. */
|
|
138
|
+
export function DealFieldLabel(fieldName) {
|
|
139
|
+
return DEAL_FIELD_LABELS[fieldName] ?? SplitFieldName(fieldName);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* What the lock notice lists as still editable.
|
|
143
|
+
*
|
|
144
|
+
* DEAL STATUS IS IN HERE AND NOT IN `DealFieldsEditableWhileLocked`, which looks like a
|
|
145
|
+
* contradiction and is not. That set is what the SERVER accepts in a bare save, and golive#205 asks
|
|
146
|
+
* for a bare status write to be refused on every path — the status moves through `Sales.CloseDeal`
|
|
147
|
+
* and `Sales.ReopenDeal`, which the form's status control routes to. So the field IS editable to a
|
|
148
|
+
* person and IS NOT writable by a raw save, and one set cannot say both.
|
|
149
|
+
*
|
|
150
|
+
* The notice describes what a PERSON can do, so it is the one that carries Deal Status.
|
|
151
|
+
*
|
|
152
|
+
* It takes `isLost` because the set it wraps does: golive#206 keeps Loss Notes editable on a lost
|
|
153
|
+
* deal and frozen on a won one, so the notice must name it on one and not the other.
|
|
154
|
+
*/
|
|
155
|
+
export function DealFieldsListedAsEditable(isLost) {
|
|
156
|
+
return ['DealStatusTypeID', ...DealFieldsEditableWhileLocked(isLost)];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Join labels the way a sentence does: "A, B and C".
|
|
160
|
+
*
|
|
161
|
+
* Oxford-comma-free and with "and" before the last, because this lands mid-sentence in prose a tester
|
|
162
|
+
* wrote, not in a bulleted list.
|
|
163
|
+
*/
|
|
164
|
+
export function JoinLabels(labels) {
|
|
165
|
+
if (labels.length === 0)
|
|
166
|
+
return '';
|
|
167
|
+
if (labels.length === 1)
|
|
168
|
+
return labels[0];
|
|
169
|
+
return `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
|
|
49
170
|
}
|
|
50
171
|
/** Sales' deal-status type table. Named here so the lock lookup below has one spelling of it. */
|
|
51
172
|
const E_DEAL_STATUS_TYPE = 'MJ_BizApps_Sales: Deal Status Types';
|
|
@@ -68,7 +189,7 @@ const E_DEAL_STATUS_TYPE = 'MJ_BizApps_Sales: Deal Status Types';
|
|
|
68
189
|
* @param contextUser - Server callers must pass one; the browser omits it.
|
|
69
190
|
*/
|
|
70
191
|
export async function ResolveDealLockState(persistedStatusID, contextUser) {
|
|
71
|
-
const open = { IsLocked: false, StatusName: null, Notice: null };
|
|
192
|
+
const open = { IsLocked: false, StatusName: null, IsLost: false, IsWon: false, Notice: null };
|
|
72
193
|
if (!persistedStatusID) {
|
|
73
194
|
return open;
|
|
74
195
|
}
|
|
@@ -76,46 +197,80 @@ export async function ResolveDealLockState(persistedStatusID, contextUser) {
|
|
|
76
197
|
EntityName: E_DEAL_STATUS_TYPE,
|
|
77
198
|
ExtraFilter: `ID = '${String(persistedStatusID).replace(/'/g, "''")}'`,
|
|
78
199
|
ResultType: 'simple',
|
|
79
|
-
Fields: ['LocksDeal', 'Name'],
|
|
200
|
+
Fields: ['LocksDeal', 'Name', 'IsLost', 'IsWon'],
|
|
80
201
|
}, contextUser);
|
|
81
202
|
const row = result?.Success ? (result.Results ?? [])[0] : undefined;
|
|
203
|
+
/**
|
|
204
|
+
* THE OUTCOME SURVIVES THE EARLY RETURN, THE LOCK DOES NOT.
|
|
205
|
+
*
|
|
206
|
+
* `IsWon` is a fact about the status itself, so it is carried out of every exit below rather than
|
|
207
|
+
* being reset to `false` by the unlocked one. The cost of getting this backwards is silent: the
|
|
208
|
+
* header would simply draw no chips on a won-but-unlocked deal, which looks exactly like a deal
|
|
209
|
+
* with no order and no contract.
|
|
210
|
+
*/
|
|
211
|
+
const isWon = row?.IsWon === true;
|
|
82
212
|
if (!row?.LocksDeal) {
|
|
83
|
-
return open;
|
|
213
|
+
return { ...open, IsWon: isWon };
|
|
84
214
|
}
|
|
85
|
-
|
|
215
|
+
/**
|
|
216
|
+
* golive#207 row 16, in the tester's words: "This deal is closed (Won). Only Deal Status,
|
|
217
|
+
* Description and Next Step can be edited. To change anything else, set the status back to Open."
|
|
218
|
+
*
|
|
219
|
+
* DERIVED rather than hardcoded to those three. The tester wrote that list when the editable set
|
|
220
|
+
* held two fields; golive#206 item 3 expands it. A hardcoded sentence would have started lying the
|
|
221
|
+
* moment that landed, and the lie would be invisible — it reads perfectly either way.
|
|
222
|
+
*
|
|
223
|
+
* What went, and why it is no loss: the old notice explained WHY the deal is frozen ("a contract or
|
|
224
|
+
* an order was derived from it"). A person who has just been stopped wants to know what they can do,
|
|
225
|
+
* not the provenance argument, and the reason is one click away in the close history.
|
|
226
|
+
*/
|
|
227
|
+
const isLost = row.IsLost === true;
|
|
228
|
+
const editable = JoinLabels(DealFieldsListedAsEditable(isLost).map(DealFieldLabel));
|
|
86
229
|
return {
|
|
87
230
|
IsLocked: true,
|
|
88
231
|
StatusName: row.Name,
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
232
|
+
IsLost: isLost,
|
|
233
|
+
IsWon: isWon,
|
|
234
|
+
Notice: `This deal is closed (${row.Name}). Only ${editable} can be edited. ` +
|
|
235
|
+
'To change anything else, set the status back to Open.',
|
|
92
236
|
};
|
|
93
237
|
}
|
|
94
238
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* THE DEFECT IT NAMES. The close lock reads the PERSISTED status, so Open -> Won is a save on an
|
|
98
|
-
* unlocked deal and passes straight through it. The deal ends up locked with none of the close having
|
|
99
|
-
* happened — no stage event, no contract, no finance tasks, no loss reason, an order still live — and
|
|
100
|
-
* the lock then refuses `DealStatusTypeID` on every later save, so it cannot be undone either.
|
|
239
|
+
* Every active deal status, with its lock flag, for a surface that has to offer a choice.
|
|
101
240
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
241
|
+
* RETURNS ALL OF THEM AND LETS THE CALLER FILTER, deliberately. A closed deal still has to SHOW the
|
|
242
|
+
* status it is in — a control that simply dropped the locking ones would render a won deal as blank
|
|
243
|
+
* or "— choose —", which reads as data loss. The workspace already solves it this way: it offers the
|
|
244
|
+
* non-locking ones and adds the deal's own status back as a display-only option.
|
|
105
245
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* Pure, so the decision can be pinned without a deal, a status table or a save.
|
|
246
|
+
* `LocksDeal` rather than `IsWon || IsLost`, because that is the flag the server's refusal reads.
|
|
247
|
+
* They coincide on today's data — Won, Lost and Abandoned carry both — but a surface filtering on a
|
|
248
|
+
* different flag would eventually offer a status the server then refuses, which is the drift this
|
|
249
|
+
* module exists to prevent.
|
|
111
250
|
*/
|
|
112
|
-
export function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
251
|
+
export async function LoadDealStatusOptions(contextUser) {
|
|
252
|
+
const result = await new RunView().RunView({
|
|
253
|
+
EntityName: E_DEAL_STATUS_TYPE,
|
|
254
|
+
ExtraFilter: 'IsActive = 1',
|
|
255
|
+
OrderBy: 'DisplayRank',
|
|
256
|
+
ResultType: 'simple',
|
|
257
|
+
// Every field read below must be listed here. A field declared on the row type and left
|
|
258
|
+
// out of this list arrives `undefined`, and a flag check against it quietly never fires --
|
|
259
|
+
// which is how ActivitySyncProviderType.IsActive did nothing for a release.
|
|
260
|
+
Fields: ['ID', 'Name', 'LocksDeal', 'IsWon', 'IsLost'],
|
|
261
|
+
}, contextUser);
|
|
262
|
+
if (!result?.Success) {
|
|
263
|
+
// An empty list leaves the control with nothing to offer, which is visibly wrong and therefore
|
|
264
|
+
// reportable. Inventing a list from somewhere else would hide a failed lookup behind a control
|
|
265
|
+
// that looks like it is working.
|
|
266
|
+
return [];
|
|
118
267
|
}
|
|
119
|
-
return
|
|
268
|
+
return (result.Results ?? []).map((r) => ({
|
|
269
|
+
ID: String(r.ID),
|
|
270
|
+
Name: String(r.Name),
|
|
271
|
+
LocksDeal: r.LocksDeal === true,
|
|
272
|
+
IsWon: r.IsWon === true,
|
|
273
|
+
IsLost: r.IsLost === true,
|
|
274
|
+
}));
|
|
120
275
|
}
|
|
121
276
|
//# sourceMappingURL=close-lock.js.map
|