@mj-biz-apps/contracts-core-entities-server 0.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/ContractEntityServer.d.ts +352 -0
- package/dist/ContractEntityServer.d.ts.map +1 -0
- package/dist/ContractEntityServer.js +729 -0
- package/dist/ContractEntityServer.js.map +1 -0
- package/dist/ContractTemplateEntityServer.d.ts +39 -0
- package/dist/ContractTemplateEntityServer.d.ts.map +1 -0
- package/dist/ContractTemplateEntityServer.js +99 -0
- package/dist/ContractTemplateEntityServer.js.map +1 -0
- package/dist/ContractTemplateModificationEntityServer.d.ts +119 -0
- package/dist/ContractTemplateModificationEntityServer.d.ts.map +1 -0
- package/dist/ContractTemplateModificationEntityServer.js +242 -0
- package/dist/ContractTemplateModificationEntityServer.js.map +1 -0
- package/dist/ContractTemplateProvisionEntityServer.d.ts +74 -0
- package/dist/ContractTemplateProvisionEntityServer.d.ts.map +1 -0
- package/dist/ContractTemplateProvisionEntityServer.js +171 -0
- package/dist/ContractTemplateProvisionEntityServer.js.map +1 -0
- package/dist/ContractTypeEntityServers.d.ts +28 -0
- package/dist/ContractTypeEntityServers.d.ts.map +1 -0
- package/dist/ContractTypeEntityServers.js +65 -0
- package/dist/ContractTypeEntityServers.js.map +1 -0
- package/dist/SupersedeOperation.d.ts +53 -0
- package/dist/SupersedeOperation.d.ts.map +1 -0
- package/dist/SupersedeOperation.js +112 -0
- package/dist/SupersedeOperation.js.map +1 -0
- package/dist/delete-guard.d.ts +89 -0
- package/dist/delete-guard.d.ts.map +1 -0
- package/dist/delete-guard.js +107 -0
- package/dist/delete-guard.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,729 @@
|
|
|
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 `ContractEntityServer` — the rules that CANNOT run in a browser.
|
|
9
|
+
*
|
|
10
|
+
* Registered later than the shared `ContractEntity`, so MJ's ClassFactory resolves this one on the
|
|
11
|
+
* server while the browser keeps the shared class (plan §6.3). Everything a client should be able to
|
|
12
|
+
* preflight lives on the shared class instead; what is here needs a lock or a cross-entity read.
|
|
13
|
+
*
|
|
14
|
+
* @module @mj-biz-apps/contracts-core-entities-server
|
|
15
|
+
*/
|
|
16
|
+
import { BaseEntity, LogError, ValidationErrorInfo, ValidationErrorType, } from '@memberjunction/core';
|
|
17
|
+
import { RegisterClass, UUIDsEqual } from '@memberjunction/global';
|
|
18
|
+
import { ContractEntity, IsSameContractLevel } from '@mj-biz-apps/contracts-entities';
|
|
19
|
+
import { GuardedDelete, plural } from './delete-guard.js';
|
|
20
|
+
/**
|
|
21
|
+
* Whether a foreign key is being CHOSEN right now, as opposed to merely being present.
|
|
22
|
+
*
|
|
23
|
+
* A free function so it can be unit-tested against a duck-typed record without a provider — the same
|
|
24
|
+
* reason `ValidateValueLists` and `ExplainMissingRequiredFields` are free functions. The semantics
|
|
25
|
+
* are the whole point and they are easy to get subtly wrong, so they are pinned by tests rather than
|
|
26
|
+
* by this comment:
|
|
27
|
+
*
|
|
28
|
+
* · NEW record -> any value is a new selection. (accounting's identity lock skips this
|
|
29
|
+
* case, correctly for its purpose and wrongly for ours)
|
|
30
|
+
* · SAVED, key changed -> a new selection.
|
|
31
|
+
* · SAVED, key the same -> NOT a new selection, whatever the referenced row now says.
|
|
32
|
+
*
|
|
33
|
+
* The comparison is case-insensitive and string-wise because these are UUIDs, which MJ can hand back
|
|
34
|
+
* in either casing depending on how the record was loaded (MJ's UUID_COMPARISON_GUIDE). A `!==` here
|
|
35
|
+
* would report a change on a record nobody touched.
|
|
36
|
+
*/
|
|
37
|
+
export function IsNewlySelected(entity, fieldName) {
|
|
38
|
+
if (!entity.IsSaved)
|
|
39
|
+
return true;
|
|
40
|
+
const field = entity.GetFieldByName(fieldName);
|
|
41
|
+
if (!field)
|
|
42
|
+
return false;
|
|
43
|
+
return String(field.OldValue ?? '').toLowerCase() !== String(field.Value ?? '').toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
/** A retired lookup row. The string is the value the `IN (...)` CHECK constrains `Status` to. */
|
|
46
|
+
const RETIRED = 'Inactive';
|
|
47
|
+
/** Guards an ID before it is interpolated into the R-3 lineage walk. */
|
|
48
|
+
const UUID_SHAPE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
49
|
+
let ContractEntityServer = class ContractEntityServer extends ContractEntity {
|
|
50
|
+
constructor() {
|
|
51
|
+
super(...arguments);
|
|
52
|
+
/**
|
|
53
|
+
* Cleared on nothing, and that is safe: a contract's type can change between saves, but this cache
|
|
54
|
+
* lives only for the duration of one `ValidateAsync` call tree — the field is re-read from the
|
|
55
|
+
* database on the next validation because a new entity instance is what a new save uses.
|
|
56
|
+
*/
|
|
57
|
+
this.cachedTypeRule = undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mint `ContractNumber` on first save, then persist.
|
|
61
|
+
*
|
|
62
|
+
* WHY THE NUMBER IS MINTED HERE and not defaulted in SQL. It comes from a singleton counter that
|
|
63
|
+
* has to be taken under a lock inside the save's own transaction, so a rollback releases the
|
|
64
|
+
* number instead of burning it. A column default cannot do that, and a client cannot be trusted
|
|
65
|
+
* with it.
|
|
66
|
+
*
|
|
67
|
+
* Contracts needs no `SkipRelatedCollections` dance, unlike orders: nothing here prepares child
|
|
68
|
+
* rows before the graph executor runs, so the plain save carries `Modifications` with it.
|
|
69
|
+
*/
|
|
70
|
+
async Save() {
|
|
71
|
+
try {
|
|
72
|
+
// MINT WHENEVER THE INCOMING VALUE IS ABSENT, and treat blank as absent.
|
|
73
|
+
//
|
|
74
|
+
// The column is NULLABLE as of V202608211200 — MJ cannot express "NOT NULL, assigned by the
|
|
75
|
+
// server on insert" (MJ#4001), and both workarounds broke creation: a DB DEFAULT stops
|
|
76
|
+
// spCreateContract from compiling (MJ#4000), and marking the field read-only drops it from
|
|
77
|
+
// the insert payload entirely. So the database no longer holds this invariant and THIS LINE
|
|
78
|
+
// does. That is the trade, and it is why blank is handled and not just null: a form that
|
|
79
|
+
// posts an empty string, a seed script, or an import would otherwise persist '' as a
|
|
80
|
+
// contract number and the filtered unique index would happily accept exactly one of them.
|
|
81
|
+
if (!this.IsSaved && !(this.ContractNumber ?? '').trim()) {
|
|
82
|
+
this.ContractNumber = await this.assignContractNumber();
|
|
83
|
+
// Tell the shared guard this CTR-… came from the sequence, not from a person. Without
|
|
84
|
+
// it, `refuseReservedContractNumber` would refuse the number we just minted — `Save()`
|
|
85
|
+
// validates below, and on an unsaved record a system-assigned number is
|
|
86
|
+
// indistinguishable from a hand-typed one by shape alone.
|
|
87
|
+
this.NumberWasSystemAssigned = true;
|
|
88
|
+
}
|
|
89
|
+
return await super.Save();
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
LogError(`ContractEntityServer.Save failed for contract ${this.ContractNumber ?? this.ID}: ${err}`);
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Server-authoritative rules that need to read another entity.
|
|
98
|
+
*
|
|
99
|
+
* SETTLE THE `HasModifications` UNKNOWN CASE. The shared `ContractEntity.Validate()` only rejects
|
|
100
|
+
* a false flag when it can PROVE rows exist — `Modifications.Count > 0`. On an ordinary form load
|
|
101
|
+
* the collection is not loaded, so `Count === 0` means *unknown*, and the shared guard stays
|
|
102
|
+
* silent by design rather than refusing a legitimate save.
|
|
103
|
+
*
|
|
104
|
+
* That design is only honest if something settles the unknown case, and until now nothing did:
|
|
105
|
+
* load a saved contract without its modifications, set `HasModifications = false`, save, and it
|
|
106
|
+
* succeeded — flag false with rows in the table, which ERD §4.4 explicitly rejects and which the
|
|
107
|
+
* modification subclass's own comment calls the single most misleading state this app can produce.
|
|
108
|
+
* The shared class's comment PROMISED this check existed. Caught on review of PR #9.
|
|
109
|
+
*
|
|
110
|
+
* One `EXISTS`, and only when it can matter (`IsSaved && HasModifications === false`), so the
|
|
111
|
+
* ordinary save pays nothing. The message is the shared class's, deliberately — the user should
|
|
112
|
+
* not be able to tell which tier refused them.
|
|
113
|
+
*
|
|
114
|
+
* THE TYPE DECIDES WHERE IN THE TREE A CONTRACT MAY SIT, and whether it needs its own paper.
|
|
115
|
+
* `ContractType.MustBeRoot` / `MustBeChild` / `TemplateRequired` — three booleans, all read from the
|
|
116
|
+
* one memoised type row. The rules need that row, which is a join the browser has no business
|
|
117
|
+
* doing, so the shared class cannot carry them (ERD §7.1).
|
|
118
|
+
*
|
|
119
|
+
* ⚠ TWO GENERATIONS OF DEFECT ARE BURIED HERE, and both are worth remembering.
|
|
120
|
+
*
|
|
121
|
+
* FIRST, this compared the type's NAME to the string 'Change Order'. A display name is not a rule:
|
|
122
|
+
* renaming that lookup row — an ordinary thing to do — silently stopped the check from ever firing,
|
|
123
|
+
* with nothing failing and no error to notice (fixed 2026-08-19).
|
|
124
|
+
*
|
|
125
|
+
* SECOND, the column that replaced the name match was a THREE-STATE STRING,
|
|
126
|
+
* `ParentStatusRequirement IN ('Required','Prohibited')` or NULL. Ruled out by Marcelo on
|
|
127
|
+
* 2026-08-20 as confusing and overly complex, with the transposition argument as the proof: a
|
|
128
|
+
* column whose values INVERT the rule when read in the wrong order is a column that will
|
|
129
|
+
* eventually be read in the wrong order. Now two booleans, mutually exclusive by
|
|
130
|
+
* `CK_ContractType_RootOrChild`, and "both false" means unrestricted — a real and in fact majority
|
|
131
|
+
* state (V202608200300).
|
|
132
|
+
*
|
|
133
|
+
* Both placement directions are enforced, because a rule that only ever checks one of them is half
|
|
134
|
+
* decoration: `MustBeChild` refuses a missing parent (a change order that amends nothing would
|
|
135
|
+
* never appear in the original agreement's lineage), and `MustBeRoot` refuses a present one (a root
|
|
136
|
+
* agreement sitting under another contract is a lineage ring waiting to happen — see R-3).
|
|
137
|
+
*/
|
|
138
|
+
async ValidateAsync() {
|
|
139
|
+
const result = await super.ValidateAsync();
|
|
140
|
+
if (this.IsSaved && this.HasModifications === false && (await this.modificationRowsExist())) {
|
|
141
|
+
result.Success = false;
|
|
142
|
+
result.Errors.push(new ValidationErrorInfo('HasModifications', `Contract ${this.ContractNumber ?? ''} has modifications recorded against the standard ` +
|
|
143
|
+
`agreement, so it cannot be marked as unmodified. Remove the modifications first if the ` +
|
|
144
|
+
`agreement really is standard.`, this.HasModifications, ValidationErrorType.Failure));
|
|
145
|
+
}
|
|
146
|
+
if (this.ContractTypeID) {
|
|
147
|
+
const rule = await this.typeRule();
|
|
148
|
+
// R-4 — WHERE IN THE TREE this type may sit. Two booleans replacing the three-state
|
|
149
|
+
// `ParentStatusRequirement` string (V202608200300). Both false = unrestricted, which is what
|
|
150
|
+
// two of the four seeded types want, and the CHECK guarantees they are never both true.
|
|
151
|
+
if (rule?.MustBeChild && !this.ParentContractID) {
|
|
152
|
+
result.Success = false;
|
|
153
|
+
result.Errors.push(new ValidationErrorInfo('ParentContractID', `Contract ${this.ContractNumber ?? ''} is a ${rule.Name}, which must name the contract it ` +
|
|
154
|
+
`changes. One that names nothing amends nothing, and would not appear in the original ` +
|
|
155
|
+
`agreement's lineage.`, this.ParentContractID, ValidationErrorType.Failure));
|
|
156
|
+
}
|
|
157
|
+
else if (rule?.MustBeRoot && this.ParentContractID) {
|
|
158
|
+
result.Success = false;
|
|
159
|
+
result.Errors.push(new ValidationErrorInfo('ParentContractID', `Contract ${this.ContractNumber ?? ''} is a ${rule.Name}, which stands on its own and cannot ` +
|
|
160
|
+
`sit under another contract. Record the relationship the other way round, or change the ` +
|
|
161
|
+
`contract type.`, this.ParentContractID, ValidationErrorType.Failure));
|
|
162
|
+
}
|
|
163
|
+
// R-4 — DOES THIS TYPE NEED ITS OWN PAPER. A separate axis from placement: a type could
|
|
164
|
+
// want any combination, which is why `TemplateRequired` is its own column rather than
|
|
165
|
+
// inferred from the flags above.
|
|
166
|
+
if (rule?.TemplateRequired && !this.ContractTemplateID) {
|
|
167
|
+
result.Success = false;
|
|
168
|
+
result.Errors.push(new ValidationErrorInfo('ContractTemplateID', `A ${rule.Name} must reference the agreement version it incorporates. Choose the template ` +
|
|
169
|
+
`whose standard terms this contract is written against.`, this.ContractTemplateID, ValidationErrorType.Failure));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await this.refuseClearingAReferencedTemplate(result);
|
|
173
|
+
await this.refuseUnusableTemplate(result);
|
|
174
|
+
await this.refuseRetiredSelections(result);
|
|
175
|
+
// Runs BEFORE both lineage guards: each of them skips the self-referential case, and this call
|
|
176
|
+
// is what makes that skip a deduplication rather than an assumption.
|
|
177
|
+
this.refuseSelfReferences(result);
|
|
178
|
+
await this.refuseLineageCycles(result);
|
|
179
|
+
await this.refuseCrossLevelSupersession(result);
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* R-8 — a contract that other records point at cannot be deleted, and the refusal says who.
|
|
184
|
+
*
|
|
185
|
+
* THREE DEPENDENCIES, and the two lineage ones are the reason this is not obvious. A contract is
|
|
186
|
+
* referenced not only by its modifications but by any contract naming it as `ParentContractID`
|
|
187
|
+
* (its change orders) or as `SupersededByContractID` (the agreement it replaced). Both of those are
|
|
188
|
+
* self-references on `Contract`, so the rows blocking the delete are *other contracts* — and the
|
|
189
|
+
* user is looking at the record they wanted gone, not at the ones pointing to it. Naming them is
|
|
190
|
+
* the whole value.
|
|
191
|
+
*
|
|
192
|
+
* Deleting a superseded contract is the case worth spelling out: it is the *predecessor* in a
|
|
193
|
+
* re-papering chain, so removing it destroys the history the successor exists to continue.
|
|
194
|
+
*/
|
|
195
|
+
async Delete(options) {
|
|
196
|
+
const label = this.ContractNumber ?? this.ID;
|
|
197
|
+
return GuardedDelete(this, options, `Contract ${label} cannot be deleted.`, [
|
|
198
|
+
{
|
|
199
|
+
EntityName: 'MJ_BizApps_Contracts: Contract Template Modifications',
|
|
200
|
+
Filter: `ContractID = '${this.ID}'`,
|
|
201
|
+
Describe: (n) => `It records ${plural(n, 'modification')} to the standard agreement — the record of what was ` +
|
|
202
|
+
`negotiated. Remove ${n === 1 ? 'it' : 'them'} first if this contract really should go.`,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
EntityName: 'MJ_BizApps_Contracts: Contracts',
|
|
206
|
+
Filter: `ParentContractID = '${this.ID}'`,
|
|
207
|
+
Describe: (n) => `${plural(n, 'contract')} ${n === 1 ? 'names' : 'name'} it as a parent, so deleting it would ` +
|
|
208
|
+
`leave ${n === 1 ? 'a change order' : 'change orders'} amending nothing.`,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
EntityName: 'MJ_BizApps_Contracts: Contracts',
|
|
212
|
+
Filter: `SupersededByContractID = '${this.ID}'`,
|
|
213
|
+
Describe: (n) => `${plural(n, 'contract')} ${n === 1 ? 'was' : 'were'} superseded BY it, so deleting it would ` +
|
|
214
|
+
`break the re-papering history those contracts point at.`,
|
|
215
|
+
},
|
|
216
|
+
], (o) => super.Delete(o));
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* R-5 — A RETIRED TYPE MUST NOT BE *NEWLY* SELECTED, and the word doing the work is "newly".
|
|
220
|
+
*
|
|
221
|
+
* A contract that already references a type keeps working. We assume the type was Active when it
|
|
222
|
+
* was chosen, and retiring a type is not a statement about the contracts already signed under it —
|
|
223
|
+
* refusing to save an existing contract because a lookup row was retired years later would make
|
|
224
|
+
* the retirement itself a destructive act. So this is a rule about the EDIT, not about the row.
|
|
225
|
+
*
|
|
226
|
+
* "Newly selected" means one of exactly two things, and both must be covered:
|
|
227
|
+
* · the record is NEW and names the type at all, or
|
|
228
|
+
* · the record is SAVED and the foreign key CHANGED.
|
|
229
|
+
*
|
|
230
|
+
* That first clause is why this does not reuse `GLAccountEntityServer`'s shape verbatim.
|
|
231
|
+
* Accounting's identity lock returns early when `OldValue` is null — correct there, because it is
|
|
232
|
+
* asking "did an existing value change". Here the same early return would skip every CREATE, which
|
|
233
|
+
* is the primary case the rule exists for: the Configuration page promises a retired type "stops
|
|
234
|
+
* being offered for new contracts", and a create is the newest selection there is.
|
|
235
|
+
*
|
|
236
|
+
* TIER: code only, no trigger. `Status` lives on another table so a CHECK cannot see it, and the
|
|
237
|
+
* plan ruled a trigger not worth it — the failure mode is a person picking a retired value in our
|
|
238
|
+
* own UI, not silent corruption by an outside writer.
|
|
239
|
+
*
|
|
240
|
+
* COST: the `ContractTypeID` half is FREE — `typeRule()` already reads that row for the
|
|
241
|
+
* parent-status rule, and `Status` now rides along on the same RunView. The `ContractTemplateID`
|
|
242
|
+
* half costs two reads (template -> its type -> that type's `Status`) and only runs when the
|
|
243
|
+
* template FK is newly selected, which is rare. There is no single view exposing the template's
|
|
244
|
+
* type's status; `vwContractTemplates` carries the type's NAME, not its `Status`.
|
|
245
|
+
*/
|
|
246
|
+
async refuseRetiredSelections(result) {
|
|
247
|
+
if (this.ContractTypeID && this.isNewlySelected('ContractTypeID')) {
|
|
248
|
+
const rule = await this.typeRule();
|
|
249
|
+
if (rule?.Status === RETIRED) {
|
|
250
|
+
result.Success = false;
|
|
251
|
+
result.Errors.push(new ValidationErrorInfo('ContractTypeID', `"${rule.Name}" has been retired and cannot be chosen for a contract. Existing ` +
|
|
252
|
+
`contracts of this type are unaffected — pick a current type for this one.`, this.ContractTypeID, ValidationErrorType.Failure));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (this.ContractTemplateID && this.isNewlySelected('ContractTemplateID')) {
|
|
256
|
+
const retired = await this.retiredTemplateType();
|
|
257
|
+
if (retired) {
|
|
258
|
+
result.Success = false;
|
|
259
|
+
result.Errors.push(new ValidationErrorInfo('ContractTemplateID', `That agreement template is a "${retired}", which has been retired and cannot be ` +
|
|
260
|
+
`chosen for a contract. Existing contracts referencing it are unaffected — pick a ` +
|
|
261
|
+
`template of a current type.`, this.ContractTemplateID, ValidationErrorType.Failure));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Whether this foreign key is being chosen right now, as opposed to merely being present.
|
|
267
|
+
*
|
|
268
|
+
* On a NEW record any value is a new selection. On a SAVED one, only a change counts — and the
|
|
269
|
+
* comparison is case-insensitively string-wise because these are UUIDs, which MJ can hand back in
|
|
270
|
+
* either casing depending on the path that loaded them (see MJ's UUID_COMPARISON_GUIDE). Comparing
|
|
271
|
+
* them with `!==` would report a change on a record nobody touched, which would refuse saves of
|
|
272
|
+
* contracts whose type is perfectly legal.
|
|
273
|
+
*/
|
|
274
|
+
isNewlySelected(fieldName) {
|
|
275
|
+
return IsNewlySelected(this, fieldName);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The NAME of the template's type when that type is retired, or null.
|
|
279
|
+
*
|
|
280
|
+
* Two hops, because `Status` lives on `ContractTemplateType` and the contract points at a
|
|
281
|
+
* `ContractTemplate`. Returns the type's name rather than a boolean so the refusal can say WHICH
|
|
282
|
+
* kind of template was retired — "that template is an Order Form, which we no longer issue" is
|
|
283
|
+
* actionable where "invalid template" sends the user back to the same picker.
|
|
284
|
+
*/
|
|
285
|
+
async retiredTemplateType() {
|
|
286
|
+
const template = await this.RunViewProviderToUse.RunView({
|
|
287
|
+
EntityName: 'MJ_BizApps_Contracts: Contract Templates',
|
|
288
|
+
ExtraFilter: `ID = '${this.ContractTemplateID}'`,
|
|
289
|
+
Fields: ['ContractTemplateTypeID'],
|
|
290
|
+
ResultType: 'simple',
|
|
291
|
+
}, this.ContextCurrentUser);
|
|
292
|
+
const typeID = template?.Results?.[0]?.ContractTemplateTypeID;
|
|
293
|
+
// A template we cannot read is not a template we can call retired. Staying silent leaves the
|
|
294
|
+
// FK constraint as the floor, which is the right failure for a missing row.
|
|
295
|
+
if (!typeID)
|
|
296
|
+
return null;
|
|
297
|
+
const type = await this.RunViewProviderToUse.RunView({
|
|
298
|
+
EntityName: 'MJ_BizApps_Contracts: Contract Template Types',
|
|
299
|
+
ExtraFilter: `ID = '${typeID}'`,
|
|
300
|
+
Fields: ['Name', 'Status'],
|
|
301
|
+
ResultType: 'simple',
|
|
302
|
+
}, this.ContextCurrentUser);
|
|
303
|
+
const row = type?.Results?.[0];
|
|
304
|
+
return row?.Status === RETIRED ? row.Name : null;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* R-3 — a contract may not end up in its own lineage, on either axis.
|
|
308
|
+
*
|
|
309
|
+
* THE HOLE. `CK_Contract_ParentNotSelf` and `CK_Contract_SupersededNotSelf` stop only `A -> A`.
|
|
310
|
+
* Nothing stopped `A -> B -> A`, or a longer ring, because every individual save is legal in
|
|
311
|
+
* isolation: setting B's parent to A is fine, and later setting A's parent to B is fine — the row
|
|
312
|
+
* being written references a different row, which is all a CHECK can see.
|
|
313
|
+
*
|
|
314
|
+
* A cycle can only be created by THE EDGE BEING ADDED, which is what makes the check cheap: the
|
|
315
|
+
* question is not "is the graph acyclic" but "starting from the parent I am proposing, can I reach
|
|
316
|
+
* myself?" One upward walk along an indexed FK, one row per level, and change-order chains are 1–3
|
|
317
|
+
* deep in practice. `Depth < 50` caps it and — more importantly — means an ALREADY-corrupt ring
|
|
318
|
+
* terminates instead of spinning.
|
|
319
|
+
*
|
|
320
|
+
* ⚠ THE CONSEQUENCE OF A CYCLE IS WORSE THAN THE PLAN SAYS, and this is worth recording because it
|
|
321
|
+
* changes the tier. R-3 argued a cycle "corrupts nothing silently — it makes the Lineage panel walk
|
|
322
|
+
* forever, which is loud." That was true when written. It is not true now: `vwContracts` computes
|
|
323
|
+
* `RootParentContractID` and `RootSupersededByContractID` through
|
|
324
|
+
* `fnContract*_GetRootID`, whose CTE walks to `Depth < 100` and then selects the row
|
|
325
|
+
* `WHERE ParentContractID IS NULL`. In a ring **no row satisfies that**, so the function returns no
|
|
326
|
+
* row and the column comes back **NULL** — silently wrong, on the app's primary view, for every
|
|
327
|
+
* contract in the ring, while also costing 100 levels of recursion per row on every read. So a
|
|
328
|
+
* cycle is quiet after all, which is exactly the shape of defect this app keeps designing away.
|
|
329
|
+
*
|
|
330
|
+
* SKIPPED ON A CREATE. An unsaved contract has an ID but no row, so nothing can point at it and no
|
|
331
|
+
* walk can reach it. Skipped when the proposed target IS this record, too — that is `A -> A`, which
|
|
332
|
+
* the generated CHECK validator already reports, and two errors for one mistake is its own defect.
|
|
333
|
+
*/
|
|
334
|
+
async refuseLineageCycles(result) {
|
|
335
|
+
if (!this.IsSaved)
|
|
336
|
+
return;
|
|
337
|
+
const axes = [
|
|
338
|
+
{
|
|
339
|
+
Field: 'ParentContractID',
|
|
340
|
+
Proposed: this.ParentContractID,
|
|
341
|
+
Message: (chain) => `That would put contract ${this.ContractNumber ?? this.ID} inside its own parent chain ` +
|
|
342
|
+
`(${chain}). A contract cannot be, directly or indirectly, its own parent — the lineage has ` +
|
|
343
|
+
`to end somewhere for the app to know which agreement is the original.`,
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
Field: 'SupersededByContractID',
|
|
347
|
+
Proposed: this.SupersededByContractID,
|
|
348
|
+
Message: (chain) => `That would make contract ${this.ContractNumber ?? this.ID} supersede itself through a chain ` +
|
|
349
|
+
`(${chain}). Re-papering has to move forward: the agreement that replaces this one cannot ` +
|
|
350
|
+
`already be replaced by it.`,
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
for (const axis of axes) {
|
|
354
|
+
const proposed = axis.Proposed;
|
|
355
|
+
if (!proposed)
|
|
356
|
+
continue;
|
|
357
|
+
if (!this.isNewlySelected(axis.Field))
|
|
358
|
+
continue;
|
|
359
|
+
// A -> A is reported by `refuseSelfReferences`, called just before this method, so skipping
|
|
360
|
+
// it here deduplicates a failure THIS CLASS has already recorded. It previously said the
|
|
361
|
+
// error "belongs to the generated CHECK validator" — an assumption about code this class does
|
|
362
|
+
// not own, and that validator turns out to miss the case where the two UUIDs differ only in
|
|
363
|
+
// casing. `ParentContractID -> self` had NO other check here at all, so that axis was relying
|
|
364
|
+
// entirely on it.
|
|
365
|
+
if (UUIDsEqual(proposed, this.ID))
|
|
366
|
+
continue;
|
|
367
|
+
const chain = await this.lineageReachesSelf(axis.Field, proposed);
|
|
368
|
+
if (chain) {
|
|
369
|
+
result.Success = false;
|
|
370
|
+
result.Errors.push(new ValidationErrorInfo(axis.Field, axis.Message(chain), proposed, ValidationErrorType.Failure));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A contract may not be its own parent, nor its own successor. Reported HERE, unconditionally.
|
|
376
|
+
*
|
|
377
|
+
* WHY THIS EXISTS when `CK_Contract_ParentNotSelf` / `CK_Contract_SupersededNotSelf` and their
|
|
378
|
+
* generated validators all cover it: because the generated validators DO NOT actually cover it.
|
|
379
|
+
* They are LLM-authored from the constraint text (`generateValidatorFunctionFromCheckConstraint`
|
|
380
|
+
* → the `CodeGen: Check Constraint Parser` prompt), and both emitted `===` on a `uniqueidentifier`:
|
|
381
|
+
*
|
|
382
|
+
* if (this.SupersededByContractID != null && this.SupersededByContractID === this.ID) { ... }
|
|
383
|
+
*
|
|
384
|
+
* MJ returns UUIDs in either casing depending on how a row was loaded, so a lowercase value against
|
|
385
|
+
* an uppercase `ID` is the SAME contract and that check silently misses it. Filed in MJ-UPSTREAM.md.
|
|
386
|
+
*
|
|
387
|
+
* AND THE RULE IS CHECKED WHOLE, not only the part the generated code misses. Covering just the
|
|
388
|
+
* casing-differs case and deferring the rest would bless a genuinely invalid state on an assumption
|
|
389
|
+
* about code this class does not own — and the moment that code changes, an invalid state starts
|
|
390
|
+
* reading as success. The cost of overlap is one duplicated message; the cost of the gap is a wrong
|
|
391
|
+
* save. This is also why the two lineage guards below delegate here rather than each deciding: one
|
|
392
|
+
* owner, checked fully, in this file, where the skip can be verified by reading it.
|
|
393
|
+
*/
|
|
394
|
+
refuseSelfReferences(result) {
|
|
395
|
+
const axes = [
|
|
396
|
+
{ Field: 'ParentContractID', Value: this.ParentContractID, Message: 'A contract cannot be its own parent.' },
|
|
397
|
+
{ Field: 'SupersededByContractID', Value: this.SupersededByContractID, Message: 'A contract cannot be superseded by itself.' },
|
|
398
|
+
];
|
|
399
|
+
for (const axis of axes) {
|
|
400
|
+
if (!axis.Value || !UUIDsEqual(axis.Value, this.ID))
|
|
401
|
+
continue;
|
|
402
|
+
result.Success = false;
|
|
403
|
+
result.Errors.push(new ValidationErrorInfo(axis.Field, axis.Message, axis.Value, ValidationErrorType.Failure));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A contract may only supersede a contract at the SAME LEVEL of the tree — same `ParentContractID`.
|
|
408
|
+
*
|
|
409
|
+
* WHY THIS IS NOT "ROOT ONLY" (ruled by Marcelo, 2026-08-20). The obvious-looking rule is that only
|
|
410
|
+
* a top-level agreement may be re-papered. It is wrong in both directions. A change order genuinely
|
|
411
|
+
* does get renegotiated and replaced by a second change order under the same order form — forbidding
|
|
412
|
+
* that would force somebody to DELETE the first one, destroying the history re-papering exists to
|
|
413
|
+
* preserve. And "root only" would still permit the incoherent case: a change order claiming to
|
|
414
|
+
* replace a whole order form, which asserts that a subordinate document replaced the agreement it
|
|
415
|
+
* hangs off.
|
|
416
|
+
*
|
|
417
|
+
* So the axis that matters is not depth, it is SIBLINGHOOD. `ParentContractID` says what a document
|
|
418
|
+
* sits under; `SupersededByContractID` says what replaced it. A supersession that crosses levels is
|
|
419
|
+
* the only genuinely meaningless combination, and same-parent is exactly the predicate that excludes
|
|
420
|
+
* it while allowing everything real: two roots (both NULL) or two siblings (same parent).
|
|
421
|
+
*
|
|
422
|
+
* TYPE IS DELIBERATELY NOT CONSTRAINED. An Order Form replaced by an Order Form is the common case,
|
|
423
|
+
* but a Payment Link customer graduating to signed paper is a real cross-type supersession — and
|
|
424
|
+
* `Payment Link -> Order Form` is precisely the upgrade path the two types exist to distinguish.
|
|
425
|
+
* Constraining `ContractTypeID` here would forbid it for no benefit.
|
|
426
|
+
*
|
|
427
|
+
* WHY THIS IS NOT A CHECK CONSTRAINT. The rule compares THIS row's `ParentContractID` with ANOTHER
|
|
428
|
+
* row's, and a CHECK sees only the row being written — the same reason `refuseLineageCycles` lives
|
|
429
|
+
* here. One indexed read of the target contract is the whole cost.
|
|
430
|
+
*
|
|
431
|
+
* RUNS ON CREATE TOO, unlike `refuseLineageCycles`. A cycle is impossible before the row exists
|
|
432
|
+
* (nothing can point at it yet), but a cross-level supersession is entirely possible on a brand-new
|
|
433
|
+
* contract — a successor composed in the browser names its predecessor before either is saved, which
|
|
434
|
+
* is the primary flow this rule exists to police. Gated on `isNewlySelected` instead, so an ordinary
|
|
435
|
+
* re-save of an existing chain is not re-policed and legacy rows are not retro-refused.
|
|
436
|
+
*/
|
|
437
|
+
async refuseCrossLevelSupersession(result) {
|
|
438
|
+
if (!this.SupersededByContractID)
|
|
439
|
+
return;
|
|
440
|
+
if (!this.isNewlySelected('SupersededByContractID'))
|
|
441
|
+
return;
|
|
442
|
+
// Reported in full by `refuseSelfReferences`, which runs immediately before this method.
|
|
443
|
+
// Comparing a contract's level against its own is meaningless, so there is nothing further to
|
|
444
|
+
// say here — this skips a SECOND message for a failure this class has already recorded, which is
|
|
445
|
+
// not the same thing as trusting another layer to catch it.
|
|
446
|
+
if (UUIDsEqual(this.SupersededByContractID, this.ID))
|
|
447
|
+
return;
|
|
448
|
+
const target = await this.RunViewProviderToUse.RunView({
|
|
449
|
+
EntityName: 'MJ_BizApps_Contracts: Contracts',
|
|
450
|
+
ExtraFilter: `ID = '${this.SupersededByContractID}'`,
|
|
451
|
+
Fields: ['ContractNumber', 'ParentContractID'],
|
|
452
|
+
ResultType: 'simple',
|
|
453
|
+
}, this.ContextCurrentUser);
|
|
454
|
+
const row = target?.Results?.[0];
|
|
455
|
+
if (!row) {
|
|
456
|
+
// A contract cannot be superseded by one that does not exist — the successor must be saved
|
|
457
|
+
// before anything can point at it. `FK_Contract_SupersededBy` would refuse this too, but it
|
|
458
|
+
// reports a constraint name and no field, so the refusal is worth stating properly.
|
|
459
|
+
result.Success = false;
|
|
460
|
+
result.Errors.push(new ValidationErrorInfo('SupersededByContractID', `The contract named as replacing ${this.ContractNumber ?? this.ID} could not be found. It may ` +
|
|
461
|
+
`have been deleted, or you may not have access to it. Pick an existing contract, or clear ` +
|
|
462
|
+
`the field if this agreement has not been re-papered.`, this.SupersededByContractID, ValidationErrorType.Failure));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const myParentID = this.ParentContractID ?? null;
|
|
466
|
+
const theirParentID = row.ParentContractID ?? null;
|
|
467
|
+
if (IsSameContractLevel(myParentID, theirParentID))
|
|
468
|
+
return;
|
|
469
|
+
// Say which side is which, because "levels differ" leaves the reader to work out whether to move
|
|
470
|
+
// the parent or pick a different successor.
|
|
471
|
+
const describe = (parent, label) => parent === null ? `${label} is a top-level agreement` : `${label} sits under another contract`;
|
|
472
|
+
result.Success = false;
|
|
473
|
+
result.Errors.push(new ValidationErrorInfo('SupersededByContractID', `Contract ${this.ContractNumber ?? this.ID} cannot be superseded by ${row.ContractNumber ?? 'that contract'}: ` +
|
|
474
|
+
`${describe(myParentID, 'this contract')}, but ${describe(theirParentID, 'that one')}. A re-papering replaces an ` +
|
|
475
|
+
`agreement with one at the same level — two top-level agreements, or two documents under the same ` +
|
|
476
|
+
`parent. Pick a successor alongside this contract, or correct whichever parent is wrong.`, this.SupersededByContractID, ValidationErrorType.Failure));
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Walk up `<column>` from `startID`; return the chain as text if it reaches this record, else null.
|
|
480
|
+
*
|
|
481
|
+
* Returns the CHAIN rather than a boolean so the refusal can show the ring — "A -> B -> A" is
|
|
482
|
+
* something a person can act on, where "cycle detected" sends them to guess which edge to remove.
|
|
483
|
+
*
|
|
484
|
+
* The column name comes from a fixed internal list, never from input, and both IDs are checked
|
|
485
|
+
* against a UUID shape before interpolation — `ExecuteSQL`'s object-parameter form needs the
|
|
486
|
+
* concrete `SQLServerDataProvider` type rather than the `DatabaseProviderBase` this class holds
|
|
487
|
+
* (see the note on `assignContractNumber`), so a guard plus interpolation is the honest option here
|
|
488
|
+
* rather than a cast that switches off checking.
|
|
489
|
+
*/
|
|
490
|
+
async lineageReachesSelf(column, startID) {
|
|
491
|
+
if (!UUID_SHAPE.test(startID) || !UUID_SHAPE.test(this.ID)) {
|
|
492
|
+
throw new Error(`Refusing to walk the ${column} lineage: '${startID}' or '${this.ID}' is not a UUID.`);
|
|
493
|
+
}
|
|
494
|
+
const rows = await this.db.ExecuteSQL(`WITH lineage AS (
|
|
495
|
+
SELECT [ID], [${column}], 1 AS Depth,
|
|
496
|
+
CAST([ContractNumber] AS NVARCHAR(MAX)) AS Chain
|
|
497
|
+
FROM __mj_BizAppsContracts.[Contract]
|
|
498
|
+
WHERE [ID] = '${startID}'
|
|
499
|
+
UNION ALL
|
|
500
|
+
SELECT c.[ID], c.[${column}], l.Depth + 1,
|
|
501
|
+
CAST(l.Chain + N' -> ' + c.[ContractNumber] AS NVARCHAR(MAX))
|
|
502
|
+
FROM __mj_BizAppsContracts.[Contract] c
|
|
503
|
+
JOIN lineage l ON c.[ID] = l.[${column}]
|
|
504
|
+
WHERE l.Depth < 50
|
|
505
|
+
)
|
|
506
|
+
SELECT TOP 1 Chain, Depth FROM lineage WHERE [ID] = '${this.ID}' ORDER BY Depth;`, undefined, { isMutation: false, description: `R-3 ${column} cycle probe` }, this.ContextCurrentUser);
|
|
507
|
+
const hit = rows?.[0];
|
|
508
|
+
return hit ? `${this.ContractNumber ?? this.ID} -> ${hit.Chain}` : null;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* A shrotcut to get the datbaseProvider.
|
|
512
|
+
*
|
|
513
|
+
* `ExecuteSQL<T>(query, parameters?, options?, contextUser?)` is a public member of
|
|
514
|
+
* `DatabaseProviderBase`, which `@memberjunction/core` exports — so the four
|
|
515
|
+
* `as unknown as { ExecuteSQL: … }` casts this replaces were never necessary. Each of them
|
|
516
|
+
* re-declared a narrower signature than the real one: no generic (forcing a second cast on every
|
|
517
|
+
* result), and no `contextUser` parameter, which made the API look as though it could not carry a
|
|
518
|
+
* context user at all. Worse, `as unknown as` switches off checking, so a signature change
|
|
519
|
+
* upstream would have compiled here and failed at runtime.
|
|
520
|
+
*
|
|
521
|
+
* `ProviderToUse` is typed as the metadata-provider interface, which does not declare
|
|
522
|
+
* `ExecuteSQL`; this is a narrowing cast to the concrete base class, which is what a server-side
|
|
523
|
+
* entity subclass always has. It is one cast in one place instead of four scattered ones, and it
|
|
524
|
+
* keeps the real generic signature.
|
|
525
|
+
*/
|
|
526
|
+
get db() {
|
|
527
|
+
return this.ProviderToUse;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Whether any modification row exists for this contract, read from the DATABASE rather than the
|
|
531
|
+
* collection — which is the whole point: the collection is exactly what the browser could not
|
|
532
|
+
* know about. `TOP 1` because existence is the question, not the count.
|
|
533
|
+
*/
|
|
534
|
+
async modificationRowsExist() {
|
|
535
|
+
// `count_only` returns no rows and populates TotalRowCount, which is exactly the question.
|
|
536
|
+
const result = await this.RunViewProviderToUse.RunView({
|
|
537
|
+
EntityName: 'MJ_BizApps_Contracts: Contract Template Modifications',
|
|
538
|
+
ExtraFilter: `ContractID = '${this.ID}'`,
|
|
539
|
+
ResultType: 'count_only',
|
|
540
|
+
}, this.ContextCurrentUser);
|
|
541
|
+
if (!result?.Success) {
|
|
542
|
+
// A failed read must not read as "no modifications" — that would let the flag be cleared
|
|
543
|
+
// on a contract whose rows simply could not be counted.
|
|
544
|
+
throw new Error(`Could not count modifications for contract ${this.ID}: ${result?.ErrorMessage ?? 'unknown error'}`);
|
|
545
|
+
}
|
|
546
|
+
return (result.TotalRowCount ?? 0) > 0;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* What this contract's TYPE says about naming a parent: `'Required'`, `'Prohibited'` or null.
|
|
550
|
+
*
|
|
551
|
+
* One scalar read, no entity load, and memoised for the call — `ValidateAsync` asks for the
|
|
552
|
+
* requirement and then for the type's name to build the message, and a validation pass should not
|
|
553
|
+
* make two round trips to say one thing.
|
|
554
|
+
*/
|
|
555
|
+
async typeRule() {
|
|
556
|
+
if (this.cachedTypeRule !== undefined)
|
|
557
|
+
return this.cachedTypeRule;
|
|
558
|
+
// `Status` rides along on a read that already happens (R-5). Asking for it here rather than in
|
|
559
|
+
// its own RunView is the difference between one round trip per validation and two, on the
|
|
560
|
+
// path every single contract save takes.
|
|
561
|
+
const result = await this.RunViewProviderToUse.RunView({
|
|
562
|
+
EntityName: 'MJ_BizApps_Contracts: Contract Types',
|
|
563
|
+
ExtraFilter: `ID = '${this.ContractTypeID}'`,
|
|
564
|
+
Fields: ['Name', 'MustBeRoot', 'MustBeChild', 'TemplateRequired', 'Status'],
|
|
565
|
+
ResultType: 'simple',
|
|
566
|
+
}, this.ContextCurrentUser);
|
|
567
|
+
this.cachedTypeRule = result?.Results?.[0] ?? null;
|
|
568
|
+
return this.cachedTypeRule;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* R-12 — a contract may not reference a template nobody can read.
|
|
572
|
+
*
|
|
573
|
+
* `IsUsable` is derived in `vwContractTemplates`: 1 when the template records a `SourceURL` **or**
|
|
574
|
+
* has a file attached, 0 when it has neither. The flag and this refusal do different jobs and both
|
|
575
|
+
* are needed (ruled 2026-08-20):
|
|
576
|
+
*
|
|
577
|
+
* · **the flag is the affordance** — someone authoring a template sees the red "Unusable" chip and
|
|
578
|
+
* fixes it, rather than being blocked by a condition they cannot see;
|
|
579
|
+
* · **this refusal is the floor** — a contract citing standard terms nobody can read is the actual
|
|
580
|
+
* harm, and it is worth stopping at the moment it would happen.
|
|
581
|
+
*
|
|
582
|
+
* Shipping only the flag leaves the harm unprevented; shipping only the refusal is the confusing
|
|
583
|
+
* version this item started as, where a person is refused for something invisible.
|
|
584
|
+
*
|
|
585
|
+
* ONLY ON A NEW REFERENCE. Whether an EXISTING contract should be affected when its template later
|
|
586
|
+
* becomes unusable is a lifecycle question, and it is R-15's — not this one. So this fires on the
|
|
587
|
+
* same `isNewlySelected` gate as R-5, which also means an ordinary re-save of an existing contract
|
|
588
|
+
* costs nothing.
|
|
589
|
+
*/
|
|
590
|
+
async refuseUnusableTemplate(result) {
|
|
591
|
+
if (!this.ContractTemplateID)
|
|
592
|
+
return;
|
|
593
|
+
if (!this.isNewlySelected('ContractTemplateID'))
|
|
594
|
+
return;
|
|
595
|
+
const template = await this.RunViewProviderToUse.RunView({
|
|
596
|
+
EntityName: 'MJ_BizApps_Contracts: Contract Templates',
|
|
597
|
+
ExtraFilter: `ID = '${this.ContractTemplateID}'`,
|
|
598
|
+
Fields: ['Name', 'IsUsable', 'Status'],
|
|
599
|
+
ResultType: 'simple',
|
|
600
|
+
}, this.ContextCurrentUser);
|
|
601
|
+
const row = template?.Results?.[0];
|
|
602
|
+
// A template we cannot read is not one we can call unusable — the FK stays the floor.
|
|
603
|
+
if (!row)
|
|
604
|
+
return;
|
|
605
|
+
// R-12 — the terms must be readable.
|
|
606
|
+
if (!row.IsUsable) {
|
|
607
|
+
result.Success = false;
|
|
608
|
+
result.Errors.push(new ValidationErrorInfo('ContractTemplateID', `"${row.Name}" has no source URL and no attached document, so nobody can read the standard ` +
|
|
609
|
+
`terms it names — a contract cannot incorporate terms that are not available. Add a URL or ` +
|
|
610
|
+
`attach the agreement document to that template first.`, this.ContractTemplateID, ValidationErrorType.Failure));
|
|
611
|
+
}
|
|
612
|
+
// V202608200900 — and the version must be PUBLISHED. A draft is still being authored: its
|
|
613
|
+
// provisions can be added to, edited and removed freely, so a contract pointing at one would
|
|
614
|
+
// have its standard terms change underneath it. This is the other half of what makes the
|
|
615
|
+
// publication freeze meaningful — the freeze protects referenced versions, and this ensures a
|
|
616
|
+
// referenced version is a frozen one.
|
|
617
|
+
//
|
|
618
|
+
// Only NEW references are policed (the isNewlySelected gate above), which is what keeps the
|
|
619
|
+
// contracts that already point at now-Draft templates valid. Same shape as R-5's retired types.
|
|
620
|
+
if (row.Status !== 'Published') {
|
|
621
|
+
result.Success = false;
|
|
622
|
+
result.Errors.push(new ValidationErrorInfo('ContractTemplateID', `"${row.Name}" is still a draft, so its clauses can change at any time — a contract cannot ` +
|
|
623
|
+
`incorporate terms that are not settled. Publish that version first, or choose one that ` +
|
|
624
|
+
`is already published.`, this.ContractTemplateID, ValidationErrorType.Failure));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* R-4 (certain half) — do not clear `ContractTemplateID` while modifications on THIS contract point
|
|
629
|
+
* at provisions of THAT template.
|
|
630
|
+
*
|
|
631
|
+
* Clearing it would leave every one of those modifications comparing negotiated language against
|
|
632
|
+
* standard language the contract no longer claims to incorporate — the modification rows survive
|
|
633
|
+
* and quietly stop meaning anything, which is the app's worst failure shape.
|
|
634
|
+
*
|
|
635
|
+
* ⚠ THIS IS THE NARROW HALF ON PURPOSE. The full rule is *"reject clearing when a modification
|
|
636
|
+
* ANYWHERE IN THE TREE references that template and it is not reachable elsewhere in the tree"*,
|
|
637
|
+
* which the plan DEFERS to ride with R-14 — not on cost (it is the same walk, and chains are 1–3
|
|
638
|
+
* deep) but on sequencing, so the ancestor walk is written once against a settled tree model. This
|
|
639
|
+
* half covers modifications on this contract only. A modification on a CHILD contract pointing at
|
|
640
|
+
* the template this contract is clearing is **not yet caught here**; the modification-side rule
|
|
641
|
+
* below refuses to SAVE such a row, so the gap is the pre-existing row, not a new one.
|
|
642
|
+
*/
|
|
643
|
+
async refuseClearingAReferencedTemplate(result) {
|
|
644
|
+
if (!this.IsSaved)
|
|
645
|
+
return;
|
|
646
|
+
if (this.ContractTemplateID)
|
|
647
|
+
return; // not being cleared
|
|
648
|
+
if (!this.isNewlySelected('ContractTemplateID'))
|
|
649
|
+
return; // was already empty
|
|
650
|
+
const field = this.GetFieldByName('ContractTemplateID');
|
|
651
|
+
const previousTemplateID = field?.OldValue ? String(field.OldValue) : null;
|
|
652
|
+
if (!previousTemplateID)
|
|
653
|
+
return;
|
|
654
|
+
const affected = await this.modificationsAgainstTemplate(previousTemplateID);
|
|
655
|
+
if (affected === 0)
|
|
656
|
+
return;
|
|
657
|
+
result.Success = false;
|
|
658
|
+
result.Errors.push(new ValidationErrorInfo('ContractTemplateID', `This contract records ${affected} modification(s) to provisions of the agreement version it ` +
|
|
659
|
+
`currently references, so the reference cannot be removed — those modifications would be left ` +
|
|
660
|
+
`describing deviations from terms the contract no longer claims to incorporate. Remove the ` +
|
|
661
|
+
`modifications first, or point the contract at a different version instead of clearing it.`, this.ContractTemplateID, ValidationErrorType.Failure));
|
|
662
|
+
}
|
|
663
|
+
/** How many of THIS contract's modifications cite a provision of the given template. */
|
|
664
|
+
async modificationsAgainstTemplate(templateID) {
|
|
665
|
+
if (!UUID_SHAPE.test(templateID) || !UUID_SHAPE.test(this.ID)) {
|
|
666
|
+
throw new Error(`Refusing to count modifications: '${templateID}' or '${this.ID}' is not a UUID.`);
|
|
667
|
+
}
|
|
668
|
+
const rows = await this.db.ExecuteSQL(`SELECT COUNT(*) AS Hits
|
|
669
|
+
FROM __mj_BizAppsContracts.[ContractTemplateModification] m
|
|
670
|
+
JOIN __mj_BizAppsContracts.[ContractTemplateProvision] p ON p.[ID] = m.[ContractTemplateProvisionID]
|
|
671
|
+
WHERE m.[ContractID] = '${this.ID}' AND p.[ContractTemplateID] = '${templateID}';`, undefined, { isMutation: false, description: 'R-4 modifications-against-template count' }, this.ContextCurrentUser);
|
|
672
|
+
return Number(rows?.[0]?.Hits ?? 0);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Mint the next contract number by calling the database's own numbering procedure.
|
|
676
|
+
*
|
|
677
|
+
* WHY A SPROC AND NOT `RunView` / `RunQuery` / `BaseEntity`. This is an atomic
|
|
678
|
+
* read-modify-write, and none of those can express one: `RunView` is read-only, `RunQuery` is a
|
|
679
|
+
* read surface (hiding DML inside a "query" would be worse than being honest about it), and the
|
|
680
|
+
* entity path removes the lock — two concurrent creates would both read N, both mint the same
|
|
681
|
+
* number, and the collision would surface later as a unique-index violation on the insert rather
|
|
682
|
+
* than as a serialised wait.
|
|
683
|
+
*
|
|
684
|
+
* So the lock stays in the database, in a DATABASE OBJECT rather than a TypeScript string.
|
|
685
|
+
* `spAssignNextContractNumber` (V202608192200) holds the `HOLDLOCK, UPDLOCK` and the format; this
|
|
686
|
+
* method is one dialect-free `EXEC`. That is accounting's pattern, adopted rather than invented —
|
|
687
|
+
* `SequenceService.ts` there keeps `spAssignNextJournalEntryNumber` at DB level for the same
|
|
688
|
+
* stated reason. Orders still inlines the same UPDATE in four places, which is what this stops
|
|
689
|
+
* doing.
|
|
690
|
+
*
|
|
691
|
+
* It also makes the PostgreSQL port a database exercise: write a PG function with this name and
|
|
692
|
+
* signature and no application code changes.
|
|
693
|
+
*
|
|
694
|
+
* `isMutation: true` matters and was missing while this was inline: MJ's SQL-statement logging
|
|
695
|
+
* uses it to know a statement CHANGED data, which is what lets a logging session capture the
|
|
696
|
+
* mutation. `description` names the operation in that log.
|
|
697
|
+
*
|
|
698
|
+
* ⚠ NOT gap-free. On a standalone save the number is consumed before the row's own transaction
|
|
699
|
+
* opens, so a later failure has already spent it; inside a graph save a rollback releases it. The
|
|
700
|
+
* unique index is the real guard.
|
|
701
|
+
*/
|
|
702
|
+
async assignContractNumber() {
|
|
703
|
+
const rows = await this.db.ExecuteSQL(`DECLARE @contractNumber NVARCHAR(50);
|
|
704
|
+
EXEC __mj_BizAppsContracts.spAssignNextContractNumber @ContractNumber = @contractNumber OUTPUT;
|
|
705
|
+
SELECT @contractNumber AS ContractNumber;`,
|
|
706
|
+
// No parameters: the sproc takes only an OUTPUT, declared in the batch above. Accounting
|
|
707
|
+
// passes an OBJECT here because its sprocs take inputs and `ExecuteSQL` binds objects BY
|
|
708
|
+
// NAME while an array binds positionally as @p0 — worth knowing before adding a parameter
|
|
709
|
+
// to this call, and worth noting that the object form needs the SQLServerDataProvider type
|
|
710
|
+
// rather than the DatabaseProviderBase one this uses.
|
|
711
|
+
undefined, { isMutation: true, description: 'spAssignNextContractNumber' }, this.ContextCurrentUser);
|
|
712
|
+
const assigned = rows?.[0]?.ContractNumber;
|
|
713
|
+
if (!assigned) {
|
|
714
|
+
throw new Error(`Could not obtain the next contract number — spAssignNextContractNumber returned nothing. ` +
|
|
715
|
+
`The number comes from the schema-owned SQL sequence seq_ContractNumber (V202608200200); ` +
|
|
716
|
+
`check that the sequence and the procedure both exist in __mj_BizAppsContracts.`);
|
|
717
|
+
}
|
|
718
|
+
return assigned;
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
ContractEntityServer = __decorate([
|
|
722
|
+
RegisterClass(BaseEntity, 'MJ_BizApps_Contracts: Contracts')
|
|
723
|
+
], ContractEntityServer);
|
|
724
|
+
export { ContractEntityServer };
|
|
725
|
+
/** Anti-tree-shake anchor — see the note in index.ts. */
|
|
726
|
+
export function LoadContractEntityServer() {
|
|
727
|
+
void ContractEntityServer;
|
|
728
|
+
}
|
|
729
|
+
//# sourceMappingURL=ContractEntityServer.js.map
|