@mj-biz-apps/sales-entities 6.2.0 → 6.3.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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/product-filter.d.ts +16 -0
- package/dist/product-filter.d.ts.map +1 -1
- package/dist/product-filter.js +32 -0
- package/dist/product-filter.js.map +1 -1
- package/dist/term-start.d.ts +105 -0
- package/dist/term-start.d.ts.map +1 -0
- package/dist/term-start.js +137 -0
- package/dist/term-start.js.map +1 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export * from './downstream-seams.js';
|
|
|
28
28
|
* all apply the same one.
|
|
29
29
|
*/
|
|
30
30
|
export * from './product-filter.js';
|
|
31
|
+
export * from './term-start.js';
|
|
31
32
|
/**
|
|
32
33
|
* The close lock's field rule, shared by `DealEntityServer.Save()` and the Explorer Deal form so the
|
|
33
34
|
* form cannot offer a field the server will refuse. Pinned by integration check CD14.
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAE7B;;;GAGG;AACH,cAAc,cAAc,CAAC;AAE7B;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAC5C;AACD,cAAc,0BAA0B,CAAC;AAEzC,wFAAwF;AACxF,cAAc,qCAAqC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export * from './downstream-seams.js';
|
|
|
28
28
|
* all apply the same one.
|
|
29
29
|
*/
|
|
30
30
|
export * from './product-filter.js';
|
|
31
|
+
export * from './term-start.js';
|
|
31
32
|
/**
|
|
32
33
|
* The close lock's field rule, shared by `DealEntityServer.Save()` and the Explorer Deal form so the
|
|
33
34
|
* form cannot offer a field the server will refuse. Pinned by integration check CD14.
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,+BAA+B,CAAC;AAE9C;;;;GAIG;AACH,cAAc,+BAA+B,CAAC;AAE9C;;;;;;;;;GASG;AACH,cAAc,eAAe,CAAC;AAE9B;;;;GAIG;AACH,cAAc,oBAAoB,CAAC;AAEnC;;;;GAIG;AACH,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAE7B;;;GAGG;AACH,cAAc,cAAc,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB;AACrC,CAAC;AACD,cAAc,0BAA0B,CAAC;AAEzC,wFAAwF;AACxF,cAAc,qCAAqC,CAAC"}
|
package/dist/product-filter.d.ts
CHANGED
|
@@ -82,7 +82,23 @@ export interface ProductLookup {
|
|
|
82
82
|
* It is a virtual field on orders' Products view, so it costs nothing but a column.
|
|
83
83
|
*/
|
|
84
84
|
Company: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* Non-null when this product is sold as a subscription — read only for WHETHER it is set, never for
|
|
87
|
+
* its value. The rule that consumes it lives in `term-start.ts`, with the reasoning.
|
|
88
|
+
*/
|
|
89
|
+
SubscriptionTypeID: string | null;
|
|
85
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The columns the picker reads, as ONE exported list rather than a literal at the call site.
|
|
93
|
+
*
|
|
94
|
+
* WHY IT IS SHARED. `SubscriptionTypeID` is not displayed anywhere — it only decides whether a line
|
|
95
|
+
* offers a term start (#32). Drop it from the query and every product arrives without it, so no line is
|
|
96
|
+
* a subscription, no term start appears, and NOTHING reports an error: the screen quietly loses a
|
|
97
|
+
* feature. The integration check that guards against exactly that (`term-start.TS5`) has to read the
|
|
98
|
+
* same list the picker reads, or it proves only that its own copy still works — which is the drift this
|
|
99
|
+
* suite already warns about for hardcoded SKUs and re-typed filters.
|
|
100
|
+
*/
|
|
101
|
+
export declare const PRODUCT_LOOKUP_FIELDS: readonly ["ID", "Name", "SKU", "CompanyID", "Company", "SubscriptionTypeID"];
|
|
86
102
|
/**
|
|
87
103
|
* The filter that decides what a rep may select, as of `asOf`.
|
|
88
104
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-filter.d.ts","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,gBAAgB,gCAAgC,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;;;;;;;;;OAUG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"product-filter.d.ts","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,eAAO,MAAM,gBAAgB,gCAAgC,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB;;;;;;;;OAQG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;;;;;;;;;OAUG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAEvB;;;OAGG;IACH,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,qBAAqB,8EAmBmB,CAAC;AAiBtD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM,CAOnD"}
|
package/dist/product-filter.js
CHANGED
|
@@ -50,6 +50,38 @@
|
|
|
50
50
|
*/
|
|
51
51
|
/** Orders' Products entity. A SOFT reference — no FK crosses the schema boundary (D-SW3). */
|
|
52
52
|
export const E_ORDERS_PRODUCT = 'MJ_BizApps_Orders: Products';
|
|
53
|
+
/**
|
|
54
|
+
* The columns the picker reads, as ONE exported list rather than a literal at the call site.
|
|
55
|
+
*
|
|
56
|
+
* WHY IT IS SHARED. `SubscriptionTypeID` is not displayed anywhere — it only decides whether a line
|
|
57
|
+
* offers a term start (#32). Drop it from the query and every product arrives without it, so no line is
|
|
58
|
+
* a subscription, no term start appears, and NOTHING reports an error: the screen quietly loses a
|
|
59
|
+
* feature. The integration check that guards against exactly that (`term-start.TS5`) has to read the
|
|
60
|
+
* same list the picker reads, or it proves only that its own copy still works — which is the drift this
|
|
61
|
+
* suite already warns about for hardcoded SKUs and re-typed filters.
|
|
62
|
+
*/
|
|
63
|
+
export const PRODUCT_LOOKUP_FIELDS = [
|
|
64
|
+
'ID',
|
|
65
|
+
'Name',
|
|
66
|
+
'SKU',
|
|
67
|
+
// #29: the line's company comes from the product, and the label names the owner so a rep can tell
|
|
68
|
+
// two same-named products apart. Drop either and the picker stamps undefined or loses the company.
|
|
69
|
+
'CompanyID',
|
|
70
|
+
'Company',
|
|
71
|
+
// #32: decides whether a line offers a term start at all. Drop it and the control silently vanishes
|
|
72
|
+
// from every line — which is what `term-start.TS5` exists to catch.
|
|
73
|
+
'SubscriptionTypeID',
|
|
74
|
+
/**
|
|
75
|
+
* `as const satisfies` rather than a type ANNOTATION, and the difference is the whole point.
|
|
76
|
+
*
|
|
77
|
+
* Annotating this `readonly (keyof ProductLookup)[]` widens `[number]` to `keyof ProductLookup`,
|
|
78
|
+
* which makes the completeness check below `Exclude<K, K>` — always `never`, always true. Measured:
|
|
79
|
+
* with the annotation, dropping 'CompanyID' still compiled. `satisfies` checks each member against
|
|
80
|
+
* the interface without discarding the literal types.
|
|
81
|
+
*/
|
|
82
|
+
];
|
|
83
|
+
const _everyLookupFieldIsRequested = true;
|
|
84
|
+
void _everyLookupFieldIsRequested;
|
|
53
85
|
/**
|
|
54
86
|
* The filter that decides what a rep may select, as of `asOf`.
|
|
55
87
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"product-filter.js","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,MAAM,CAAC,MAAM,gBAAgB,GAAG,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"product-filter.js","sourceRoot":"","sources":["../src/product-filter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,6FAA6F;AAC7F,MAAM,CAAC,MAAM,gBAAgB,GAAG,6BAA6B,CAAC;AA2C9D;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;IACjC,IAAI;IACJ,MAAM;IACN,KAAK;IACL,kGAAkG;IAClG,mGAAmG;IACnG,WAAW;IACX,SAAS;IACT,oGAAoG;IACpG,oEAAoE;IACpE,oBAAoB;IACpB;;;;;;;OAOG;CAC8C,CAAC;AActD,MAAM,4BAA4B,GAAyD,IAAI,CAAC;AAChG,KAAK,4BAA4B,CAAC;AAElC;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAU;IACvC,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,cAAc,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;IACxI,OAAO,CACH,oBAAoB;QACpB,mDAAmD,GAAG,KAAK;QAC3D,+CAA+C,GAAG,IAAI,CACzD,CAAC;AACN,CAAC"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The term-start rule for a deal's subscription lines (sales#32).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS A MODULE AND NOT THREE METHODS ON THE WORKSPACE COMPONENT. Two of the three decisions
|
|
5
|
+
* here are the kind that look obviously right and are quietly wrong — which of them applies is decided
|
|
6
|
+
* by whether a value is *stored* versus *shown*, and those are indistinguishable in a date control on
|
|
7
|
+
* screen. Kept as pure functions they can be asserted directly by the integration checks, which is the
|
|
8
|
+
* same shape the discount rules (`DiscountFractionToPercent`) and the picker filter (`ProductFilterFor`)
|
|
9
|
+
* already have in this package: the rule lives here, the component delegates, the checks prove it.
|
|
10
|
+
*
|
|
11
|
+
* ── WHAT SALES IS ACTUALLY DOING HERE ───────────────────────────────────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* Writing ONE column, `OrderLine.ServicePeriodStart`, and nothing else. The term END is orders' to
|
|
14
|
+
* compute at confirm from the subscription's own cadence and proration, and sales does not know either.
|
|
15
|
+
* This app states intent; orders states the term. Guessing an end date here would be sales computing a
|
|
16
|
+
* commercial term, which is the boundary rule this repo does not cross.
|
|
17
|
+
*
|
|
18
|
+
* ── AND WHAT IT DEPENDS ON ──────────────────────────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* As of this writing orders OVERWRITES the column at confirm:
|
|
21
|
+
* `OrderEntityServer.materializeSubscriptions` ends with "the line's stored service period reflects the
|
|
22
|
+
* TERM, not what a user typed" and assigns `line.ServicePeriodStart = term.StartDate`, where the term
|
|
23
|
+
* start comes from `SubscriptionBehavior.ComputeStartDate`. The companion orders issue changes that hook
|
|
24
|
+
* to honour an explicitly-set value. Until it lands, everything in this module works and survives save
|
|
25
|
+
* and reload, and the confirmed term still starts on the order date.
|
|
26
|
+
*
|
|
27
|
+
* @module @mj-biz-apps/sales-entities
|
|
28
|
+
*/
|
|
29
|
+
import type { ProductLookup } from './product-filter.js';
|
|
30
|
+
/** Either shape a date can arrive in — an entity field holds a `Date`, a `RunView` row can hold a string. */
|
|
31
|
+
export type DateLike = Date | string | null | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a product is sold as a subscription.
|
|
34
|
+
*
|
|
35
|
+
* ── THE PREDICATE IS ORDERS', NOT OURS ──
|
|
36
|
+
*
|
|
37
|
+
* `OrderEntityServer.subscriptionLines` selects the lines that will receive subscriptions with
|
|
38
|
+
* `ID IN (...) AND SubscriptionTypeID IS NOT NULL` against this same column. Matching it exactly means
|
|
39
|
+
* a line offering a term start is exactly a line that will receive a term at confirm. Any other test —
|
|
40
|
+
* a product-type string, a name convention — could drift from that and offer a term start on a line
|
|
41
|
+
* which never gets one, which reads as a broken promise rather than a field that does nothing.
|
|
42
|
+
*
|
|
43
|
+
* A function rather than a bare `!= null` at each call site because the emptiness test is the subtle
|
|
44
|
+
* part: `RunView`'s `simple` result type returns an absent uniqueidentifier as `null`, but a row read
|
|
45
|
+
* another way can carry `''`, and `'' != null` is true — which would mark every non-subscription
|
|
46
|
+
* product as a subscription. Both shapes are rejected here, once.
|
|
47
|
+
*/
|
|
48
|
+
export declare function IsSubscriptionProduct(product: Pick<ProductLookup, 'SubscriptionTypeID'> | null | undefined): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether a line shows a term start at all.
|
|
51
|
+
*
|
|
52
|
+
* Three cases, and the third is the one worth reading:
|
|
53
|
+
*
|
|
54
|
+
* · **No product chosen** — nothing to say. A line with no `ProductID` cannot be a subscription, and it
|
|
55
|
+
* already blocks the save on its own account (`UnlinkedLineIssues`).
|
|
56
|
+
* · **Product in the catalogue** — orders' predicate decides, and that is #32's requirement 3 exactly,
|
|
57
|
+
* BUT ONLY WHEN NOTHING IS STORED. A stored value short-circuits every case here: a term start that
|
|
58
|
+
* exists is always offered, whatever the product turns out to be, so it can be seen and cleared
|
|
59
|
+
* rather than governing the line invisibly. That ordering is the fix in the body below, and this
|
|
60
|
+
* list described the rule before it — long enough that the next reader implementing requirement 3
|
|
61
|
+
* elsewhere would have copied the stranding bug back in.
|
|
62
|
+
* · **Product NOT in the catalogue** — quoted before it was withdrawn; `ProductLabel` renders it as
|
|
63
|
+
* "(no longer offered)". The lookup cannot say whether it was a subscription, and answering `false`
|
|
64
|
+
* would HIDE a term start the rep had already set: the value stays in the database, still governs the
|
|
65
|
+
* term, and has no control on screen to see or clear it. So the stored value decides — shown if there
|
|
66
|
+
* is one, absent if there is not. Nothing is invented for a line that never had one.
|
|
67
|
+
*
|
|
68
|
+
* @param hasProduct - Whether the line references a product at all.
|
|
69
|
+
* @param product - The catalogue row, or null when the product is no longer offered.
|
|
70
|
+
* @param stored - What the line currently stores in `ServicePeriodStart`.
|
|
71
|
+
*/
|
|
72
|
+
export declare function ShouldOfferTermStart(hasProduct: boolean, product: Pick<ProductLookup, 'SubscriptionTypeID'> | null | undefined, stored: DateLike): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The date the control DISPLAYS: the stored term start, or the order date as a default.
|
|
75
|
+
*
|
|
76
|
+
* ── THE DEFAULT IS DISPLAYED, NEVER WRITTEN ──
|
|
77
|
+
*
|
|
78
|
+
* This is the whole of #32's behaviour, and the two acceptance criteria that look like opposites are
|
|
79
|
+
* one rule seen twice: with nothing stored the field follows the order date, and the moment a value is
|
|
80
|
+
* stored it stops following. Writing the default onto the line at load would collapse both into "always
|
|
81
|
+
* frozen", would make every line dirty just by opening the deal, and would leave "reset to order date"
|
|
82
|
+
* with nothing to distinguish it from setting today's date by hand.
|
|
83
|
+
*
|
|
84
|
+
* ── EMPTINESS, NOT NULLISHNESS ──
|
|
85
|
+
*
|
|
86
|
+
* The fallback triggers on {@link IsEmptyDateLike}, deliberately, and NOT on `??`. This read
|
|
87
|
+
* `stored ?? orderDate` once, which meant it fell back on null and undefined only — and `'' ?? x` is
|
|
88
|
+
* `''`. `DateLike` explicitly admits `string`, and a cleared `<input type="date">` reports exactly
|
|
89
|
+
* `''`, so the code produced the outcome the paragraph above rejects: a blank date box captioned
|
|
90
|
+
* "order date", with no reset button, because `HasExplicitTermStart('')` is false while
|
|
91
|
+
* `EffectiveTermStart('')` returned the empty string.
|
|
92
|
+
*
|
|
93
|
+
* `||` would have fixed that case and taken a legitimate falsy with it. An explicit emptiness test is
|
|
94
|
+
* cheaper to read than re-deriving which falsy values `DateLike` can hold.
|
|
95
|
+
*/
|
|
96
|
+
export declare function EffectiveTermStart(stored: DateLike, orderDate: DateLike): DateLike;
|
|
97
|
+
/**
|
|
98
|
+
* Whether the line carries its own term start rather than showing the order date.
|
|
99
|
+
*
|
|
100
|
+
* The one thing a date control cannot show by itself, and the reason the workspace prints "order date"
|
|
101
|
+
* under an inherited value: an inherited default and a deliberately-chosen date look identical on
|
|
102
|
+
* screen and behave differently the moment the order date moves.
|
|
103
|
+
*/
|
|
104
|
+
export declare function HasExplicitTermStart(stored: DateLike): boolean;
|
|
105
|
+
//# sourceMappingURL=term-start.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"term-start.d.ts","sourceRoot":"","sources":["../src/term-start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,6GAA6G;AAC7G,MAAM,MAAM,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;AAExD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACjC,OAAO,EAAE,IAAI,CAAC,aAAa,EAAE,oBAAoB,CAAC,GAAG,IAAI,GAAG,SAAS,GACtE,OAAO,CAET;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,oBAAoB,CAChC,UAAU,EAAE,OAAO,EACnB,OAAO,EAAE,IAAI,CAAC,aAAa,EAAE,oBAAoB,CAAC,GAAG,IAAI,GAAG,SAAS,EACrE,MAAM,EAAE,QAAQ,GACjB,OAAO,CAgCT;AAoBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,GAAG,QAAQ,CAElF;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,QAAQ,GAAG,OAAO,CAU9D"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a product is sold as a subscription.
|
|
3
|
+
*
|
|
4
|
+
* ── THE PREDICATE IS ORDERS', NOT OURS ──
|
|
5
|
+
*
|
|
6
|
+
* `OrderEntityServer.subscriptionLines` selects the lines that will receive subscriptions with
|
|
7
|
+
* `ID IN (...) AND SubscriptionTypeID IS NOT NULL` against this same column. Matching it exactly means
|
|
8
|
+
* a line offering a term start is exactly a line that will receive a term at confirm. Any other test —
|
|
9
|
+
* a product-type string, a name convention — could drift from that and offer a term start on a line
|
|
10
|
+
* which never gets one, which reads as a broken promise rather than a field that does nothing.
|
|
11
|
+
*
|
|
12
|
+
* A function rather than a bare `!= null` at each call site because the emptiness test is the subtle
|
|
13
|
+
* part: `RunView`'s `simple` result type returns an absent uniqueidentifier as `null`, but a row read
|
|
14
|
+
* another way can carry `''`, and `'' != null` is true — which would mark every non-subscription
|
|
15
|
+
* product as a subscription. Both shapes are rejected here, once.
|
|
16
|
+
*/
|
|
17
|
+
export function IsSubscriptionProduct(product) {
|
|
18
|
+
return !!product && String(product.SubscriptionTypeID ?? '').trim().length > 0;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether a line shows a term start at all.
|
|
22
|
+
*
|
|
23
|
+
* Three cases, and the third is the one worth reading:
|
|
24
|
+
*
|
|
25
|
+
* · **No product chosen** — nothing to say. A line with no `ProductID` cannot be a subscription, and it
|
|
26
|
+
* already blocks the save on its own account (`UnlinkedLineIssues`).
|
|
27
|
+
* · **Product in the catalogue** — orders' predicate decides, and that is #32's requirement 3 exactly,
|
|
28
|
+
* BUT ONLY WHEN NOTHING IS STORED. A stored value short-circuits every case here: a term start that
|
|
29
|
+
* exists is always offered, whatever the product turns out to be, so it can be seen and cleared
|
|
30
|
+
* rather than governing the line invisibly. That ordering is the fix in the body below, and this
|
|
31
|
+
* list described the rule before it — long enough that the next reader implementing requirement 3
|
|
32
|
+
* elsewhere would have copied the stranding bug back in.
|
|
33
|
+
* · **Product NOT in the catalogue** — quoted before it was withdrawn; `ProductLabel` renders it as
|
|
34
|
+
* "(no longer offered)". The lookup cannot say whether it was a subscription, and answering `false`
|
|
35
|
+
* would HIDE a term start the rep had already set: the value stays in the database, still governs the
|
|
36
|
+
* term, and has no control on screen to see or clear it. So the stored value decides — shown if there
|
|
37
|
+
* is one, absent if there is not. Nothing is invented for a line that never had one.
|
|
38
|
+
*
|
|
39
|
+
* @param hasProduct - Whether the line references a product at all.
|
|
40
|
+
* @param product - The catalogue row, or null when the product is no longer offered.
|
|
41
|
+
* @param stored - What the line currently stores in `ServicePeriodStart`.
|
|
42
|
+
*/
|
|
43
|
+
export function ShouldOfferTermStart(hasProduct, product, stored) {
|
|
44
|
+
if (!hasProduct) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A STORED VALUE IS OFFERED REGARDLESS OF THE PRODUCT, and that ordering is the fix.
|
|
49
|
+
*
|
|
50
|
+
* This used to read `product ? IsSubscriptionProduct(product) : !!stored`, so the `stored` fallback
|
|
51
|
+
* applied only when the product was UNKNOWN. When the product was known and was not a subscription
|
|
52
|
+
* the answer was `false` no matter what was stored — the exact outcome this function's docstring
|
|
53
|
+
* says it prevents: "the value stays in the database, still governs the term, and has no control on
|
|
54
|
+
* screen to see or clear it".
|
|
55
|
+
*
|
|
56
|
+
* It is reachable in one click. A rep sets a term start on a subscription line, then re-picks that
|
|
57
|
+
* row's product as a one-time item. Nothing on either side reconciles the column —
|
|
58
|
+
* `OrderLineEntity` and `OrderLineEntityServer` never mention `ServicePeriodStart`, and orders'
|
|
59
|
+
* subscription materialisation only visits lines carrying a `SubscriptionTypeID` — so the value is
|
|
60
|
+
* written once and then never read, never cleared, and never seen.
|
|
61
|
+
*
|
|
62
|
+
* Worse than invisible: orders' confirm bails out of stamping an event line's own service period
|
|
63
|
+
* with `if (line.ServicePeriodStart || line.ServicePeriodEnd) return;`, and revenue recognition
|
|
64
|
+
* then throws for want of a service period. A stranded value turns into a failed confirm naming
|
|
65
|
+
* nothing the rep did.
|
|
66
|
+
*
|
|
67
|
+
* `OnProductChange` now clears the column when the newly chosen product is known and is not a
|
|
68
|
+
* subscription, which stops the stranding at its source. This is the second half: anything stranded
|
|
69
|
+
* by another route stays visible and clearable rather than silently governing a term.
|
|
70
|
+
*/
|
|
71
|
+
if (HasExplicitTermStart(stored)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return product ? IsSubscriptionProduct(product) : false;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Is this `DateLike` absent for our purposes?
|
|
78
|
+
*
|
|
79
|
+
* Absent means null, undefined, a blank or whitespace-only string, or a Date that does not parse. The
|
|
80
|
+
* three predicates in this module disagreed about that before — `??` passed `''` through while `!!`
|
|
81
|
+
* rejected it, so one function said "no term start" while another rendered the empty string — and a
|
|
82
|
+
* shared test is what stops them drifting again.
|
|
83
|
+
*/
|
|
84
|
+
function IsEmptyDateLike(value) {
|
|
85
|
+
if (value === null || value === undefined) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (value instanceof Date) {
|
|
89
|
+
return Number.isNaN(value.getTime());
|
|
90
|
+
}
|
|
91
|
+
return String(value).trim().length === 0;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The date the control DISPLAYS: the stored term start, or the order date as a default.
|
|
95
|
+
*
|
|
96
|
+
* ── THE DEFAULT IS DISPLAYED, NEVER WRITTEN ──
|
|
97
|
+
*
|
|
98
|
+
* This is the whole of #32's behaviour, and the two acceptance criteria that look like opposites are
|
|
99
|
+
* one rule seen twice: with nothing stored the field follows the order date, and the moment a value is
|
|
100
|
+
* stored it stops following. Writing the default onto the line at load would collapse both into "always
|
|
101
|
+
* frozen", would make every line dirty just by opening the deal, and would leave "reset to order date"
|
|
102
|
+
* with nothing to distinguish it from setting today's date by hand.
|
|
103
|
+
*
|
|
104
|
+
* ── EMPTINESS, NOT NULLISHNESS ──
|
|
105
|
+
*
|
|
106
|
+
* The fallback triggers on {@link IsEmptyDateLike}, deliberately, and NOT on `??`. This read
|
|
107
|
+
* `stored ?? orderDate` once, which meant it fell back on null and undefined only — and `'' ?? x` is
|
|
108
|
+
* `''`. `DateLike` explicitly admits `string`, and a cleared `<input type="date">` reports exactly
|
|
109
|
+
* `''`, so the code produced the outcome the paragraph above rejects: a blank date box captioned
|
|
110
|
+
* "order date", with no reset button, because `HasExplicitTermStart('')` is false while
|
|
111
|
+
* `EffectiveTermStart('')` returned the empty string.
|
|
112
|
+
*
|
|
113
|
+
* `||` would have fixed that case and taken a legitimate falsy with it. An explicit emptiness test is
|
|
114
|
+
* cheaper to read than re-deriving which falsy values `DateLike` can hold.
|
|
115
|
+
*/
|
|
116
|
+
export function EffectiveTermStart(stored, orderDate) {
|
|
117
|
+
return IsEmptyDateLike(stored) ? (orderDate ?? null) : stored;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Whether the line carries its own term start rather than showing the order date.
|
|
121
|
+
*
|
|
122
|
+
* The one thing a date control cannot show by itself, and the reason the workspace prints "order date"
|
|
123
|
+
* under an inherited value: an inherited default and a deliberately-chosen date look identical on
|
|
124
|
+
* screen and behave differently the moment the order date moves.
|
|
125
|
+
*/
|
|
126
|
+
export function HasExplicitTermStart(stored) {
|
|
127
|
+
/**
|
|
128
|
+
* An UNPARSEABLE value is not an explicit term start.
|
|
129
|
+
*
|
|
130
|
+
* `!!new Date('nonsense')` is true, and the formatter applies `getUTCFullYear()` with no NaN guard,
|
|
131
|
+
* so the field rendered "NaN-NaN-NaN" — which `<input type="date">` rejects and shows blank. The
|
|
132
|
+
* app then believed the line carried a deliberate term start: reset button shown, hint suppressed,
|
|
133
|
+
* and no fallback to the order date.
|
|
134
|
+
*/
|
|
135
|
+
return !IsEmptyDateLike(stored);
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=term-start.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"term-start.js","sourceRoot":"","sources":["../src/term-start.ts"],"names":[],"mappings":"AAiCA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,qBAAqB,CACjC,OAAqE;IAErE,OAAO,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,OAAO,CAAC,kBAAkB,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;AACnF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,oBAAoB,CAChC,UAAmB,EACnB,OAAqE,EACrE,MAAgB;IAEhB,IAAI,CAAC,UAAU,EAAE,CAAC;QACd,OAAO,KAAK,CAAC;IACjB,CAAC;IACD;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,IAAI,oBAAoB,CAAC,MAAM,CAAC,EAAE,CAAC;QAC/B,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,OAAO,OAAO,CAAC,CAAC,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AAC5D,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,eAAe,CAAC,KAAe;IACpC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxC,OAAO,IAAI,CAAC;IAChB,CAAC;IACD,IAAI,KAAK,YAAY,IAAI,EAAE,CAAC;QACxB,OAAO,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;IACzC,CAAC;IACD,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAgB,EAAE,SAAmB;IACpE,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;AAClE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAgB;IACjD;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;AACpC,CAAC"}
|