Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 31 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,32 +73,44 @@ accounts in **Setup → Users** and assign the seven positions:

| Position | How many | What it changes |
|---|---|---|
| `clm_legal_counsel` | 2 | Receives contracts on submission (round-robin by open load) and owns review |
| `clm_legal_counsel` | 2 | Receives contracts on submission (round-robin by open load) and owns review. **Name these two exactly** — see below |
| `clm_legal_head` | 1 | Rung 2 of the approval ladder whenever `route_legal_head` is stamped |
| `clm_finance_controller` | 1 | Rung 2/3, and the only position that may edit bank details |
| `clm_executive` | 1 | Rung 4, and reads every contract with `route_executive` |
| `clm_general_manager` | 1 | Rung 5, and reads every contract with `route_gm` |
| `clm_records_manager` | 1 | Execution formalities, the executed copy and the archive number |
| (business requesters) | 3 | The default `clm_requester` set — launches contracts, sees their own |

**Name the three business requesters exactly** `Business Requester 1`,
`Business Requester 2` and `Business Requester 3`. The 120 contracts are dealt
across those three names by counterparty — 43 / 43 / 34 — so each one's
**我的合同 › Launched by Me** fills the moment the account exists. `owner_id` is
optional, so naming an account that is not there yet costs nothing and no row:
it simply stays empty. Every dataset is an upsert, so **create the accounts and
run `pnpm demo` again** and their contracts are handed over.

The dev admin is deliberately not one of them: it holds no `clm_*` permission
set, so `clm_requester.access` hides every 我的合同 item from it and
`GET /api/v1/meta/app/clm` serves it `navigation: []`. Contracts parked there
would belong to the one account that cannot open the screen they are for.

`clm_review.reviewer`, `clm_obligation.owner` and `clm_contract.legal_owner`
point at the dev admin instead, and have to: `reviewer` is required, so a name
that resolves to nothing takes the row with it, and the dev admin is the only
account that exists while the seed runs. Reassign them once the real accounts
are there.
**Five of these accounts have to be named exactly**, because the fixture
references them by name:

| Name it exactly | Give it | It receives |
|---|---|---|
| `Business Requester 1` · `Business Requester 2` · `Business Requester 3` | the default `clm_requester` set | the 120 contracts' `owner_id`, dealt by counterparty — 43 / 43 / 34 |
| `Legal Counsel 1` · `Legal Counsel 2` | the `clm_legal_counsel` position | the `legal_owner` of every contract legal has accepted — 34 / 34 |

Each requester's **我的合同 › Launched by Me** and each lawyer's **法务工作台 ›
审查中** fills the moment the account exists. Both columns are optional
references, so naming an account that is not there yet costs nothing and no
row: it simply stays empty. Every dataset is an upsert, so **create the accounts
and run `pnpm demo` again** and their contracts are handed over. Get a name
wrong by one character and the column stays empty with no error — that is the
one thing to double-check.

The other five positions are named however you like; they are reached through
the position, not by name.

The dev admin is deliberately none of them: it holds no `clm_*` permission set,
so `clm_requester.access` hides every 我的合同 item from it,
`GET /api/v1/meta/app/clm` serves it `navigation: []`, and `clm_legal.access`
gates the legal workbench away too. A contract parked there — as a business
owner or as a legal owner — belongs to the one account that cannot open the
screen it is for, and its reminders go to somebody who cannot act on them.

`clm_review.reviewer` and `clm_obligation.owner` still point at the dev admin,
and `reviewer` has to: it is `required: true`, so a name that resolves to
nothing takes the row with it, and the dev admin is the only account that exists
while the seed runs. Reassign those two once the real accounts are there.

### What the seeded rows do NOT carry, and why

Expand Down
12 changes: 10 additions & 2 deletions src/data/contract.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Contract } from '../objects/contract.object.js';

import { dayOffset } from './_shared.js';
import { STRINGS } from './demo-locale.js';
import { contractTitle, ownerOf, titleOfIndex, DEMO_USER } from './keys.js';
import { contractTitle, legalOwnerOf, ownerOf, titleOfIndex } from './keys.js';
import { CONTRACT_PLAN, lawOf, typeOf, type ContractPlan } from './plan-contracts.js';

/**
Expand Down Expand Up @@ -99,7 +99,15 @@ export const contractSeed = defineSeed(Contract, {
//
// Which requester owns which contract is {@link ownerOf} in `keys.ts`.
owner_id: ownerOf(contract),
legal_owner: contract.hasLegalOwner ? DEMO_USER : null,
// Which lawyer accepted it is {@link legalOwnerOf} in `keys.ts`, and
// WHETHER one did is `hasLegalOwner` in `plan-contracts.ts`. Both halves
// are deliberate and both leave rows empty on purpose: F2 assigns no
// legal owner to a draft, to a `submitted` contract still in the 待受理
// queue, or to a type that skips legal review — and two contracts in
// review are handed back on purpose, so F3's "nobody to tell" edge stays
// exercised (`UNASSIGNED_IN_REVIEW`). ⛔ Filling every row would delete
// the coverage card 09 measured.
legal_owner: legalOwnerOf(contract),

amount: contract.amount,
currency_code: law.currency,
Expand Down
81 changes: 73 additions & 8 deletions src/data/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,15 @@ import { assertUniqueKeys } from './_shared.js';
* `pnpm demo:zh` are meant to be pointed at their own databases.
*/

/** The account the demo is driven as — the platform's dev admin. */
/**
* The account the demo is driven as — the platform's dev admin.
*
* The only account that exists while the seed runs, so it is what every
* REQUIRED user reference has to name: `clm_review.reviewer` is
* `required: true`, and a name resolving to nothing costs the whole row.
* `clm_contract.legal_owner` is no longer one of them — see
* {@link LEGAL_OWNERS} for what it names now and why.
*/
export const DEMO_USER = 'Dev Admin';

/**
Expand All @@ -49,15 +57,18 @@ export const DEMO_USER = 'Dev Admin';
* screen they are meant to fill. The accounts that CAN are the ones DESIGN.md
* §10 has the operator create in Setup, so those are the names the deal uses.
*
* That is also why this field and `legal_owner` name different accounts, which
* is not an inconsistency but the difference between the two references:
* That is also why the OPTIONAL user references in this fixture name operator
* accounts while the REQUIRED one names {@link DEMO_USER}, which is not an
* inconsistency but the difference between the two kinds of reference:
* `clm_review.reviewer` is `required: true`, so a name that resolves to
* nothing costs the whole row and it must be an account that exists AT SEED
* TIME — the dev admin, the only one there is. `owner_id` is optional, so it
* may name an account that does not exist yet: measured on 17.3.0, an
* unresolvable name lands NULL, silently, and never refuses the row. Every
* dataset is an upsert, so creating the accounts and running `pnpm demo` again
* hands their contracts over. The README says so where the operator reads it.
* TIME — the dev admin, the only one there is. `owner_id` and
* `clm_contract.legal_owner` are optional, so they may name accounts that do
* not exist yet: measured on 17.3.0 for `owner_id` and re-measured on 17.4.0
* for `legal_owner`, an unresolvable name lands NULL, silently, and never
* refuses the row. Every dataset is an upsert, so creating the accounts and
* running `pnpm demo` again hands their contracts over. The README says so
* where the operator reads it.
*
* ## Why the counterparty decides
*
Expand All @@ -78,6 +89,60 @@ export const CONTRACT_OWNERS: readonly string[] = [
export const ownerOf = (contract: ContractPlan): string =>
CONTRACT_OWNERS[contract.partyIndex % CONTRACT_OWNERS.length]!;

/**
* The lawyers who accept contracts into review — the `clm_legal_counsel ×2` of
* DESIGN.md §10's persona mix, as ACCOUNT NAMES resolved against
* `sys_user.name` exactly like {@link CONTRACT_OWNERS}.
*
* ## Why not {@link DEMO_USER}, which is what this column held before
*
* Measured on this fixture at `a7b7db5`, one run of each scheduled job after
* the README operator setup: **44 notification receipts, and every one of the
* 5 that `legal_owner` addressed carried the dev admin.** A demo whose legal
* reminders are the administrator writing to themselves under-demonstrates a
* reminder layer that works — and the dev admin is the one account that cannot
* act on them: it holds no `clm_*` permission set, so `clm_legal.access` gates
* every 法务工作台 item away from it and 审查中 (`legal_owner == me`) is a
* screen it can never open. That is the same argument {@link CONTRACT_OWNERS}
* makes for `owner_id`, and it applies here for the same reason.
*
* `legal_owner` is `Field.user` and optional, so — like `owner_id` and unlike
* the `required` `clm_review.reviewer` — it may name an account that does not
* exist yet: an unresolvable name lands NULL, silently, and never refuses the
* row. Every dataset is an upsert, so the operator creates these two accounts
* and runs `pnpm demo` again to hand the contracts over. The README names them
* where the operator reads it.
*
* ## Why the counterparty decides, and what that does NOT claim
*
* F2 (`contract_route`) assigns `legal_owner` to the counsel with the FEWEST
* OPEN contracts at the moment of submission. No fixture can replay that: the
* answer depends on which contracts were still open on each of 69 different
* historical days, and this corpus is loaded in one pass with no history to
* count. So the deal states a rule it can defend instead of pretending to
* reproduce F2's: the counterparty decides, so every contract with a given
* party is reviewed by the lawyer who knows that account — which is how a real
* legal desk works, gives each of the two a spread of types, statuses and
* amounts, and is structure, so `demo-en` and `demo-zh` deal identically.
*/
export const LEGAL_OWNERS: readonly string[] = [
'Legal Counsel 1',
'Legal Counsel 2',
];

/**
* The lawyer who accepted this contract into review, by counterparty
* relationship — or `null` where F2 would never have assigned one.
*
* `hasLegalOwner` is the fixture's mirror of F2's own condition, and the
* ownerless rows it leaves are deliberate in two different ways. See
* `plan-contracts.ts`: the type-and-status half (a draft, a `submitted`
* contract nobody has picked up, a type that skips legal review) and the
* handed-back half that keeps F3's "nobody to tell" edge exercised.
*/
export const legalOwnerOf = (contract: ContractPlan): string | null =>
contract.hasLegalOwner ? LEGAL_OWNERS[contract.partyIndex % LEGAL_OWNERS.length]! : null;

/**
* `<type> — <counterparty>`, with a qualifier when a counterparty has more
* than one contract of the same type.
Expand Down
Loading